@vellumai/assistant 0.3.15 → 0.3.18

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 (306) hide show
  1. package/ARCHITECTURE.md +211 -12
  2. package/Dockerfile +1 -1
  3. package/README.md +11 -5
  4. package/docs/architecture/http-token-refresh.md +274 -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 +328 -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 +19 -15
  22. package/src/__tests__/checker.test.ts +103 -48
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +356 -0
  25. package/src/__tests__/conversation-pairing.test.ts +127 -27
  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 +425 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-store.test.ts +182 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  40. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  41. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  42. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  43. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  44. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  45. package/src/__tests__/hooks-runner.test.ts +13 -4
  46. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  47. package/src/__tests__/intent-routing.test.ts +14 -0
  48. package/src/__tests__/ipc-snapshot.test.ts +23 -5
  49. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  50. package/src/__tests__/memory-regressions.test.ts +16 -12
  51. package/src/__tests__/non-member-access-request.test.ts +281 -0
  52. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  53. package/src/__tests__/notification-decision-strategy.test.ts +138 -1
  54. package/src/__tests__/notification-deep-link.test.ts +44 -1
  55. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  56. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  57. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  58. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  59. package/src/__tests__/recording-intent.test.ts +1 -0
  60. package/src/__tests__/recording-state-machine.test.ts +328 -17
  61. package/src/__tests__/registry.test.ts +17 -8
  62. package/src/__tests__/relay-server.test.ts +105 -0
  63. package/src/__tests__/reminder.test.ts +13 -0
  64. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  65. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  66. package/src/__tests__/server-history-render.test.ts +8 -8
  67. package/src/__tests__/session-agent-loop.test.ts +1 -0
  68. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -0
  70. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  71. package/src/__tests__/slack-channel-config.test.ts +230 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  73. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  74. package/src/__tests__/system-prompt.test.ts +43 -0
  75. package/src/__tests__/task-management-tools.test.ts +3 -3
  76. package/src/__tests__/task-tools.test.ts +3 -3
  77. package/src/__tests__/trust-store.test.ts +38 -22
  78. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +489 -0
  79. package/src/__tests__/trusted-contact-multichannel.test.ts +405 -0
  80. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  81. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  82. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  83. package/src/__tests__/update-bulletin.test.ts +323 -0
  84. package/src/__tests__/update-template-contract.test.ts +24 -0
  85. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  86. package/src/agent/loop.ts +2 -2
  87. package/src/amazon/client.ts +2 -3
  88. package/src/calls/call-controller.ts +241 -39
  89. package/src/calls/call-conversation-messages.ts +2 -2
  90. package/src/calls/call-domain.ts +10 -3
  91. package/src/calls/call-pointer-messages.ts +17 -5
  92. package/src/calls/guardian-action-sweep.ts +77 -36
  93. package/src/calls/guardian-dispatch.ts +8 -0
  94. package/src/calls/relay-server.ts +51 -12
  95. package/src/calls/twilio-routes.ts +3 -1
  96. package/src/calls/types.ts +1 -1
  97. package/src/calls/voice-session-bridge.ts +8 -6
  98. package/src/cli/core-commands.ts +43 -3
  99. package/src/cli/map.ts +8 -5
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  101. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  102. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  103. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  104. package/src/config/computer-use-prompt.ts +1 -0
  105. package/src/config/core-schema.ts +16 -0
  106. package/src/config/env-registry.ts +1 -0
  107. package/src/config/env.ts +16 -1
  108. package/src/config/memory-schema.ts +5 -0
  109. package/src/config/schema.ts +4 -0
  110. package/src/config/system-prompt.ts +69 -2
  111. package/src/config/templates/BOOTSTRAP.md +1 -1
  112. package/src/config/templates/IDENTITY.md +8 -4
  113. package/src/config/templates/SOUL.md +14 -0
  114. package/src/config/templates/UPDATES.md +15 -0
  115. package/src/config/templates/USER.md +5 -1
  116. package/src/config/types.ts +1 -0
  117. package/src/config/update-bulletin-format.ts +54 -0
  118. package/src/config/update-bulletin-state.ts +49 -0
  119. package/src/config/update-bulletin-template-path.ts +6 -0
  120. package/src/config/update-bulletin.ts +97 -0
  121. package/src/config/vellum-skills/catalog.json +6 -0
  122. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  123. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  124. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  125. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  126. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  127. package/src/context/window-manager.ts +43 -3
  128. package/src/daemon/config-watcher.ts +4 -2
  129. package/src/daemon/connection-policy.ts +21 -1
  130. package/src/daemon/daemon-control.ts +219 -8
  131. package/src/daemon/date-context.ts +174 -1
  132. package/src/daemon/guardian-action-generators.ts +175 -0
  133. package/src/daemon/guardian-verification-intent.ts +120 -0
  134. package/src/daemon/handlers/apps.ts +1 -3
  135. package/src/daemon/handlers/config-channels.ts +2 -2
  136. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  137. package/src/daemon/handlers/config-inbox.ts +55 -159
  138. package/src/daemon/handlers/config-ingress.ts +1 -1
  139. package/src/daemon/handlers/config-integrations.ts +1 -1
  140. package/src/daemon/handlers/config-platform.ts +1 -1
  141. package/src/daemon/handlers/config-scheduling.ts +2 -2
  142. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  143. package/src/daemon/handlers/config-telegram.ts +1 -1
  144. package/src/daemon/handlers/config-twilio.ts +1 -1
  145. package/src/daemon/handlers/config-voice.ts +100 -0
  146. package/src/daemon/handlers/config.ts +3 -0
  147. package/src/daemon/handlers/identity.ts +45 -25
  148. package/src/daemon/handlers/misc.ts +83 -5
  149. package/src/daemon/handlers/navigate-settings.ts +27 -0
  150. package/src/daemon/handlers/recording.ts +270 -144
  151. package/src/daemon/handlers/sessions.ts +100 -17
  152. package/src/daemon/handlers/subagents.ts +3 -3
  153. package/src/daemon/handlers/work-items.ts +10 -7
  154. package/src/daemon/ipc-contract/integrations.ts +9 -1
  155. package/src/daemon/ipc-contract/messages.ts +4 -0
  156. package/src/daemon/ipc-contract/sessions.ts +1 -1
  157. package/src/daemon/ipc-contract/settings.ts +26 -0
  158. package/src/daemon/ipc-contract/shared.ts +2 -0
  159. package/src/daemon/ipc-contract/work-items.ts +1 -7
  160. package/src/daemon/ipc-contract/workspace.ts +12 -1
  161. package/src/daemon/ipc-contract-inventory.json +6 -1
  162. package/src/daemon/ipc-contract.ts +5 -1
  163. package/src/daemon/lifecycle.ts +314 -266
  164. package/src/daemon/recording-intent.ts +0 -41
  165. package/src/daemon/response-tier.ts +2 -2
  166. package/src/daemon/server.ts +31 -9
  167. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  168. package/src/daemon/session-agent-loop.ts +15 -8
  169. package/src/daemon/session-history.ts +3 -2
  170. package/src/daemon/session-media-retry.ts +3 -0
  171. package/src/daemon/session-messaging.ts +38 -4
  172. package/src/daemon/session-notifiers.ts +2 -2
  173. package/src/daemon/session-process.ts +546 -59
  174. package/src/daemon/session-queue-manager.ts +2 -0
  175. package/src/daemon/session-runtime-assembly.ts +39 -0
  176. package/src/daemon/session-skill-tools.ts +13 -4
  177. package/src/daemon/session-tool-setup.ts +5 -6
  178. package/src/daemon/session.ts +19 -8
  179. package/src/daemon/tls-certs.ts +60 -13
  180. package/src/daemon/tool-side-effects.ts +13 -5
  181. package/src/gallery/default-gallery.ts +32 -9
  182. package/src/influencer/client.ts +2 -1
  183. package/src/memory/channel-delivery-store.ts +35 -567
  184. package/src/memory/channel-guardian-store.ts +63 -1317
  185. package/src/memory/conflict-store.ts +4 -4
  186. package/src/memory/conversation-attention-store.ts +0 -3
  187. package/src/memory/conversation-crud.ts +668 -0
  188. package/src/memory/conversation-queries.ts +361 -0
  189. package/src/memory/conversation-store.ts +44 -983
  190. package/src/memory/db-connection.ts +3 -0
  191. package/src/memory/db-init.ts +33 -0
  192. package/src/memory/delivery-channels.ts +175 -0
  193. package/src/memory/delivery-crud.ts +211 -0
  194. package/src/memory/delivery-status.ts +199 -0
  195. package/src/memory/embedding-backend.ts +70 -4
  196. package/src/memory/embedding-local.ts +12 -2
  197. package/src/memory/entity-extractor.ts +3 -8
  198. package/src/memory/fts-reconciler.ts +136 -0
  199. package/src/memory/guardian-action-store.ts +418 -5
  200. package/src/memory/guardian-approvals.ts +569 -0
  201. package/src/memory/guardian-bindings.ts +130 -0
  202. package/src/memory/guardian-rate-limits.ts +196 -0
  203. package/src/memory/guardian-verification.ts +521 -0
  204. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  205. package/src/memory/job-utils.ts +8 -5
  206. package/src/memory/jobs-store.ts +66 -6
  207. package/src/memory/jobs-worker.ts +23 -1
  208. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  209. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  210. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  211. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  212. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  213. package/src/memory/migrations/100-core-tables.ts +1 -1
  214. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  215. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  216. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  217. package/src/memory/migrations/113-late-migrations.ts +1 -1
  218. package/src/memory/migrations/116-messages-fts.ts +13 -0
  219. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  220. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  221. package/src/memory/migrations/index.ts +10 -3
  222. package/src/memory/migrations/validate-migration-state.ts +114 -15
  223. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  224. package/src/memory/retriever.ts +46 -13
  225. package/src/memory/schema-migration.ts +4 -0
  226. package/src/memory/schema.ts +31 -8
  227. package/src/memory/search/semantic.ts +8 -90
  228. package/src/notifications/README.md +159 -18
  229. package/src/notifications/broadcaster.ts +69 -33
  230. package/src/notifications/conversation-pairing.ts +99 -21
  231. package/src/notifications/decision-engine.ts +176 -8
  232. package/src/notifications/deliveries-store.ts +39 -8
  233. package/src/notifications/emit-signal.ts +1 -0
  234. package/src/notifications/preferences-store.ts +7 -7
  235. package/src/notifications/thread-candidates.ts +269 -0
  236. package/src/notifications/types.ts +19 -0
  237. package/src/permissions/checker.ts +1 -16
  238. package/src/permissions/defaults.ts +25 -5
  239. package/src/permissions/prompter.ts +17 -0
  240. package/src/permissions/trust-store.ts +2 -0
  241. package/src/providers/failover.ts +19 -0
  242. package/src/providers/registry.ts +46 -1
  243. package/src/runtime/approval-message-composer.ts +1 -1
  244. package/src/runtime/channel-guardian-service.ts +15 -3
  245. package/src/runtime/channel-retry-sweep.ts +7 -2
  246. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  247. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  248. package/src/runtime/guardian-action-message-composer.ts +245 -0
  249. package/src/runtime/guardian-outbound-actions.ts +26 -6
  250. package/src/runtime/guardian-verification-templates.ts +15 -9
  251. package/src/runtime/http-errors.ts +93 -0
  252. package/src/runtime/http-server.ts +133 -44
  253. package/src/runtime/http-types.ts +53 -0
  254. package/src/runtime/ingress-service.ts +237 -0
  255. package/src/runtime/middleware/error-handler.ts +4 -3
  256. package/src/runtime/middleware/rate-limiter.ts +160 -0
  257. package/src/runtime/middleware/request-logger.ts +71 -0
  258. package/src/runtime/middleware/twilio-validation.ts +7 -6
  259. package/src/runtime/pending-interactions.ts +12 -0
  260. package/src/runtime/routes/access-request-decision.ts +215 -0
  261. package/src/runtime/routes/app-routes.ts +25 -18
  262. package/src/runtime/routes/approval-routes.ts +18 -47
  263. package/src/runtime/routes/attachment-routes.ts +15 -41
  264. package/src/runtime/routes/call-routes.ts +20 -20
  265. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  266. package/src/runtime/routes/contact-routes.ts +4 -9
  267. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  268. package/src/runtime/routes/conversation-routes.ts +26 -57
  269. package/src/runtime/routes/debug-routes.ts +71 -0
  270. package/src/runtime/routes/events-routes.ts +3 -2
  271. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  272. package/src/runtime/routes/identity-routes.ts +14 -10
  273. package/src/runtime/routes/inbound-conversation.ts +3 -2
  274. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  275. package/src/runtime/routes/ingress-routes.ts +174 -0
  276. package/src/runtime/routes/integration-routes.ts +78 -16
  277. package/src/runtime/routes/pairing-routes.ts +11 -10
  278. package/src/runtime/routes/secret-routes.ts +10 -18
  279. package/src/runtime/verification-rate-limiter.ts +83 -0
  280. package/src/schedule/schedule-store.ts +13 -1
  281. package/src/schedule/scheduler.ts +1 -1
  282. package/src/security/secret-ingress.ts +5 -2
  283. package/src/security/secret-scanner.ts +72 -6
  284. package/src/subagent/manager.ts +6 -4
  285. package/src/swarm/plan-validator.ts +4 -1
  286. package/src/tasks/task-runner.ts +3 -1
  287. package/src/tools/browser/api-map.ts +9 -6
  288. package/src/tools/calls/call-start.ts +20 -0
  289. package/src/tools/executor.ts +50 -568
  290. package/src/tools/permission-checker.ts +271 -0
  291. package/src/tools/registry.ts +14 -6
  292. package/src/tools/reminder/reminder-store.ts +7 -7
  293. package/src/tools/reminder/reminder.ts +6 -3
  294. package/src/tools/secret-detection-handler.ts +301 -0
  295. package/src/tools/subagent/message.ts +1 -1
  296. package/src/tools/system/voice-config.ts +62 -0
  297. package/src/tools/tasks/index.ts +3 -3
  298. package/src/tools/tasks/work-item-list.ts +3 -3
  299. package/src/tools/tasks/work-item-update.ts +4 -5
  300. package/src/tools/tool-approval-handler.ts +192 -0
  301. package/src/tools/tool-manifest.ts +2 -0
  302. package/src/version.ts +29 -2
  303. package/src/watcher/watcher-store.ts +9 -9
  304. package/src/work-items/work-item-runner.ts +9 -6
  305. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  306. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
package/ARCHITECTURE.md CHANGED
@@ -65,6 +65,57 @@ The policy is implemented in `src/tools/guardian-control-plane-policy.ts`, which
65
65
 
66
66
  The `guardian-verify-setup` skill is the exclusive handler for guardian verification intents in the system prompt. Other skills (e.g., `phone-calls`) hand off to `guardian-verify-setup` rather than orchestrating verification directly.
67
67
 
68
+ ### Guardian Action Timeout-to-Follow-Up Lifecycle
69
+
70
+ When a voice call's ASK_GUARDIAN consultation times out before the guardian responds, the system enters a follow-up lifecycle that allows the guardian to act on their late answer after the call has moved on. The entire flow uses LLM-generated copy (never hardcoded user-facing strings) to maintain a natural, conversational tone across voice and text channels.
71
+
72
+ **Lifecycle stages:**
73
+
74
+ ```
75
+ ASK_GUARDIAN fires on call
76
+ |
77
+ v
78
+ [pending] -- guardian answers in time --> [answered] (normal flow)
79
+ |
80
+ | (timeout expires)
81
+ v
82
+ [expired, followup_state=none]
83
+ |
84
+ | (guardian replies late)
85
+ v
86
+ [expired, followup_state=awaiting_guardian_choice]
87
+ |
88
+ | (conversation engine classifies intent)
89
+ v
90
+ call_back / message_back / decline
91
+ | |
92
+ v v
93
+ [dispatching] [declined] (terminal)
94
+ |
95
+ | (executor runs action)
96
+ v
97
+ [completed] or [failed] (terminal)
98
+ ```
99
+
100
+ **Generated messaging requirement:** All user-facing copy in the guardian timeout/follow-up path is generated through the `guardian-action-message-composer.ts` composition system, which uses a 2-tier priority chain: (1) daemon-injected LLM generator for natural, varied text; (2) deterministic fallback templates for reliability. No hardcoded user-facing strings exist in the flow files (call-controller, inbound-message-handler, session-process) outside of internal log messages and LLM-instruction prompts. A guard test (`guardian-action-no-hardcoded-copy.test.ts`) enforces this invariant.
101
+
102
+ **Callback/message-back branch:** When the conversation engine classifies the guardian's intent as `call_back`, the executor starts an outbound call to the counterparty with context about the guardian's answer. When classified as `message_back`, the executor sends an SMS to the counterparty via the gateway's `/deliver/sms` endpoint. The counterparty phone number is resolved from the original call session by call direction (inbound: `fromNumber`; outbound: `toNumber`).
103
+
104
+ **Key source files:**
105
+
106
+ | File | Purpose |
107
+ |------|---------|
108
+ | `src/memory/guardian-action-store.ts` | Follow-up state machine with atomic transitions (`startFollowupFromExpiredRequest`, `progressFollowupState`, `finalizeFollowup`) and query helpers for pending/expired/follow-up deliveries |
109
+ | `src/runtime/guardian-action-message-composer.ts` | 2-tier text generation: daemon-injected LLM generator with deterministic fallback templates. Covers all scenarios from timeout acknowledgment through follow-up completion |
110
+ | `src/runtime/guardian-action-conversation-turn.ts` | Follow-up decision engine: classifies guardian replies into `call_back`, `message_back`, `decline`, or `keep_pending` dispositions using LLM tool calling |
111
+ | `src/runtime/guardian-action-followup-executor.ts` | Action dispatch: resolves counterparty from call session, executes `message_back` (SMS via gateway) or `call_back` (outbound call via `startCall`), finalizes follow-up state |
112
+ | `src/daemon/guardian-action-generators.ts` | Daemon-injected generator factories: `createGuardianActionCopyGenerator` (latency-optimized text rewriting) and `createGuardianFollowUpConversationGenerator` (tool-calling intent classification) |
113
+ | `src/calls/call-controller.ts` | Voice timeout handling: marks requests as timed out, sends expiry notices, injects `[GUARDIAN_TIMEOUT]` instruction for generated voice response |
114
+ | `src/runtime/routes/inbound-message-handler.ts` | Late reply interception for Telegram/SMS channels: matches late answers to expired requests, routes follow-up conversation turns, dispatches actions |
115
+ | `src/daemon/session-process.ts` | Late reply interception for mac/IPC channel: same logic as inbound-message-handler but using conversation-ID-based delivery lookup |
116
+ | `src/calls/guardian-action-sweep.ts` | Periodic sweep for stale pending requests; sends expiry notices to guardian destinations |
117
+ | `src/memory/migrations/030-guardian-action-followup.ts` | Schema migration adding follow-up columns (`followup_state`, `late_answer_text`, `late_answered_at`, `followup_action`, `followup_completed_at`) |
118
+
68
119
  ### SMS Channel (Twilio)
69
120
 
70
121
  The SMS channel provides text-only messaging via Twilio, sharing the same telephony provider as voice calls. It follows the same ingress/egress pattern as Telegram but uses Twilio's HMAC-SHA1 signature validation instead of a secret header.
@@ -134,6 +185,124 @@ These can be set via environment variables or stored in the credential vault (ke
134
185
 
135
186
  **SMS Compliance & Admin**: The `twilio_config` IPC contract extends beyond credential and number management with compliance and admin actions: `sms_compliance_status` detects toll-free vs local number type and fetches verification status; `sms_submit_tollfree_verification`, `sms_update_tollfree_verification`, and `sms_delete_tollfree_verification` manage the Twilio toll-free verification lifecycle; `release_number` removes a phone number from the Twilio account and clears all local references. All compliance actions validate required fields and Twilio enum values before calling the API.
136
187
 
188
+ ### Slack Channel (Socket Mode)
189
+
190
+ The Slack channel provides text-based messaging via Slack's Socket Mode API. Unlike other channels that use HTTP webhooks, Slack uses a persistent WebSocket connection managed by the gateway — no public ingress URL is required. The assistant-side manages credential storage and validation through HTTP config endpoints.
191
+
192
+ **Control-plane endpoints** (`/v1/integrations/slack/channel/config`):
193
+
194
+ | Endpoint | Method | Description |
195
+ |----------|--------|-------------|
196
+ | `/v1/integrations/slack/channel/config` | GET | Returns current config status: `hasBotToken`, `hasAppToken`, `connected`, plus workspace metadata (`teamId`, `teamName`, `botUserId`, `botUsername`) |
197
+ | `/v1/integrations/slack/channel/config` | POST | Validates and stores credentials. Body: `{ botToken?: string, appToken?: string }` |
198
+ | `/v1/integrations/slack/channel/config` | DELETE | Clears all Slack channel credentials from secure storage and credential metadata |
199
+
200
+ All endpoints are bearer-authenticated via the runtime HTTP token (`~/.vellum/http-token`).
201
+
202
+ **Credential storage pattern:**
203
+
204
+ Both tokens are stored in the secure key store (macOS Keychain with encrypted file fallback):
205
+
206
+ | Secure key | Content |
207
+ |-----------|---------|
208
+ | `credential:slack_channel:bot_token` | Slack bot token (used for `chat.postMessage` and `auth.test`) |
209
+ | `credential:slack_channel:app_token` | Slack app token (`xapp-...`, used for Socket Mode `apps.connections.open`) |
210
+
211
+ Workspace metadata (team ID, team name, bot user ID, bot username) is stored as JSON in the credential metadata store under `('slack_channel', 'bot_token')`.
212
+
213
+ **Token validation via `auth.test`:**
214
+
215
+ When a bot token is provided via `POST /v1/integrations/slack/channel/config`, the handler calls `POST https://slack.com/api/auth.test` with the token before storing it. A successful response yields workspace metadata (`team_id`, `team`, `user_id`, `user`) that is persisted alongside the token. If `auth.test` fails, the token is rejected and not stored.
216
+
217
+ The app token is validated by format only — it must start with `xapp-`.
218
+
219
+ **Connection status:**
220
+
221
+ Both `GET` and `POST` endpoints report `connected: true` only when both `hasBotToken` and `hasAppToken` are true. The `POST` endpoint additionally returns a `warning` field when only one token is stored, describing which token is missing.
222
+
223
+ **Key source files:**
224
+
225
+ | File | Purpose |
226
+ |------|---------|
227
+ | `src/daemon/handlers/config-slack-channel.ts` | Business logic for get/set/clear Slack channel config |
228
+ | `src/runtime/routes/integration-routes.ts` | HTTP route handlers for `/v1/integrations/slack/channel/config` |
229
+
230
+ ### Trusted Contact Access (Channel-Agnostic)
231
+
232
+ External users who are not the guardian can gain access to the assistant through a guardian-mediated verification flow. The flow is channel-agnostic — it works identically on Telegram, SMS, voice, and any future channel.
233
+
234
+ **Full design doc:** [`docs/trusted-contact-access.md`](docs/trusted-contact-access.md)
235
+
236
+ **Flow summary:**
237
+ 1. Unknown user messages the assistant on any channel.
238
+ 2. Ingress ACL (`inbound-message-handler.ts`) rejects the message and emits an `ingress.access_request` notification signal to the guardian.
239
+ 3. Guardian approves or denies via callback button or conversational intent (routed through `guardian-approval-interception.ts`).
240
+ 4. On approval, an identity-bound verification session with a 6-digit code is created (`access-request-decision.ts` → `channel-guardian-service.ts`).
241
+ 5. Guardian gives the code to the requester out-of-band.
242
+ 6. Requester enters the code; identity binding is verified, the challenge is consumed, and an active member record is created in `assistant_ingress_members`.
243
+ 7. All subsequent messages are accepted through the ingress ACL.
244
+
245
+ **Channel-agnostic design:** The entire flow operates on abstract `ChannelId` and `externalUserId`/`externalChatId` fields. Identity binding adapts per channel: Telegram uses chat IDs, SMS/voice use E.164 phone numbers, HTTP API uses caller-provided identity. No channel-specific branching exists in the trusted contact code paths.
246
+
247
+ **Lifecycle states:** `requested → pending_guardian → verification_pending → active | denied | expired`
248
+
249
+ **Notification signals:** The flow emits signals at each lifecycle transition via `emitNotificationSignal()`:
250
+ - `ingress.access_request` — non-member denied, guardian notified
251
+ - `ingress.trusted_contact.guardian_decision` — guardian approved or denied
252
+ - `ingress.trusted_contact.verification_sent` — code created and delivered
253
+ - `ingress.trusted_contact.activated` — requester verified, member active
254
+ - `ingress.trusted_contact.denied` — guardian explicitly denied
255
+
256
+ **HTTP API (for management):**
257
+
258
+ | Endpoint | Method | Description |
259
+ |----------|--------|-------------|
260
+ | `/v1/ingress/members` | GET | List trusted contacts (filterable by channel, status, policy) |
261
+ | `/v1/ingress/members` | POST | Upsert a member (add/update trusted contact) |
262
+ | `/v1/ingress/members/:id` | DELETE | Revoke a trusted contact |
263
+ | `/v1/ingress/members/:id/block` | POST | Block a member |
264
+
265
+ **Key source files:**
266
+
267
+ | File | Purpose |
268
+ |------|---------|
269
+ | `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL, non-member rejection, verification code interception |
270
+ | `src/runtime/routes/access-request-decision.ts` | Guardian decision → verification session creation |
271
+ | `src/runtime/routes/guardian-approval-interception.ts` | Routes guardian decisions (button + conversational) to access request handler |
272
+ | `src/runtime/channel-guardian-service.ts` | Verification challenge lifecycle, identity binding, rate limiting |
273
+ | `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management |
274
+ | `src/runtime/ingress-service.ts` | Business logic for member CRUD |
275
+ | `src/memory/ingress-member-store.ts` | Member record persistence |
276
+ | `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
277
+ | `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |
278
+
279
+ ### Update Bulletin System
280
+
281
+ Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
282
+
283
+ **Data flow:**
284
+ 1. **Bundled template** (`src/config/templates/UPDATES.md`) — source of release notes, maintained per-release in the repo.
285
+ 2. **Startup sync** (`syncUpdateBulletinOnStartup()` in `src/config/update-bulletin.ts`) — materializes the bundled template into the workspace `UPDATES.md` on daemon boot. Uses atomic write (temp + rename) for crash safety.
286
+ 3. **System prompt injection** — `buildSystemPrompt()` reads workspace `UPDATES.md` and injects it as a `## Recent Updates` section with judgment-based handling instructions.
287
+ 4. **Completion by deletion** — the assistant deletes `UPDATES.md` when it has actioned all updates. Next startup detects the deletion and marks those releases as completed in checkpoint state.
288
+ 5. **Cross-release merge** — if pending updates from a prior release exist when a new release lands, both release blocks coexist in the same file.
289
+
290
+ **Checkpoint keys** (in `memory_checkpoints` table):
291
+ - `updates:active_releases` — JSON array of version strings currently active.
292
+ - `updates:completed_releases` — JSON array of version strings already completed.
293
+
294
+ **Key source files:**
295
+
296
+ | File | Purpose |
297
+ |------|---------|
298
+ | `src/config/templates/UPDATES.md` | Bundled release-note template |
299
+ | `src/config/update-bulletin.ts` | Startup sync logic (materialize, delete-complete, merge) |
300
+ | `src/config/update-bulletin-format.ts` | Release block formatter/parser helpers |
301
+ | `src/config/update-bulletin-state.ts` | Checkpoint state helpers for active/completed releases |
302
+ | `src/config/system-prompt.ts` | Prompt injection of updates section |
303
+ | `src/daemon/config-watcher.ts` | File watcher — evicts sessions on UPDATES.md changes |
304
+ | `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + rm UPDATES.md |
305
+
137
306
  ---
138
307
 
139
308
 
@@ -1401,9 +1570,10 @@ Keep-alive heartbeats (every 30 s by default):
1401
1570
  The notification module (`assistant/src/notifications/`) uses a signal-based architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user. See `assistant/src/notifications/README.md` for the full developer guide.
1402
1571
 
1403
1572
  ```
1404
- Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1405
- ↑ ↓
1406
- Preference Summary notification_thread_created IPC
1573
+ Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1574
+ ↑ ↓
1575
+ Preference Summary notification_thread_created IPC
1576
+ Thread Candidates (creation-only — not emitted on reuse)
1407
1577
  ```
1408
1578
 
1409
1579
  ### Channel Policy Registry
@@ -1418,13 +1588,18 @@ Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Chec
1418
1588
 
1419
1589
  Helper functions: `getDeliverableChannels()`, `getChannelPolicy()`, `isNotificationDeliverable()`, `getConversationStrategy()`.
1420
1590
 
1421
- ### Conversation Pairing
1591
+ ### Conversation Pairing and Thread Routing
1592
+
1593
+ Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). The pairing function now accepts a `threadAction` from the decision engine:
1594
+
1595
+ - **`reuse_existing`**: Looks up the target conversation. If valid (exists with `source: 'notification'`), the seed message is appended to the existing thread. If invalid, falls back to creating a new conversation with `threadDecisionFallbackUsed: true`.
1596
+ - **`start_new` (default)**: Creates a fresh conversation per delivery.
1422
1597
 
1423
- Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). This ensures:
1598
+ This ensures:
1424
1599
 
1425
1600
  1. Every delivery has an auditable conversation trail in the conversations table
1426
1601
  2. The macOS/iOS client can deep-link directly into the notification thread
1427
- 3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, and `conversation_strategy` columns
1602
+ 3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, and `thread_decision_fallback_used` columns
1428
1603
 
1429
1604
  The pairing function (`pairDeliveryWithConversation`) is resilient — errors are caught and logged without breaking the delivery pipeline.
1430
1605
 
@@ -1435,19 +1610,42 @@ The notification pipeline uses a single conversation materialization path across
1435
1610
  1. **Canonical pipeline** (`emitNotificationSignal` → decision engine → broadcaster → conversation pairing → adapters): The broadcaster pairs each delivery with a conversation, then dispatches a `notification_intent` IPC event via the Vellum adapter. The IPC payload includes `deepLinkMetadata` (e.g. `{ conversationId }`) so the macOS/iOS client can deep-link to the relevant context when the user taps the notification.
1436
1611
  2. **Guardian bookkeeping** (`dispatchGuardianQuestion`): Guardian dispatch creates `guardian_action_request` / `guardian_action_delivery` audit rows derived from pipeline delivery results and the per-dispatch `onThreadCreated` callback — there is no separate thread-creation path.
1437
1612
 
1438
- ### Thread Surfacing via `notification_thread_created` IPC
1613
+ ### Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
1439
1614
 
1440
- When a vellum notification thread is paired with a conversation (strategy `start_new_conversation`), the broadcaster emits a `notification_thread_created` IPC event **immediately** (before waiting for slower channel deliveries like Telegram). This pushes the thread to the macOS/iOS client so it can display the notification thread in the sidebar and deep-link to it.
1615
+ The `notification_thread_created` IPC event is emitted **only when a brand-new conversation is created** by the broadcaster. Reusing an existing thread does not trigger this event the macOS/iOS client already knows about the conversation from the original creation. This is enforced in `broadcaster.ts` by gating on `pairing.createdNewConversation === true`.
1616
+
1617
+ When a new vellum notification thread is created (strategy `start_new_conversation`), the broadcaster emits the IPC event **immediately** (before waiting for slower channel deliveries like Telegram). This pushes the thread to the macOS/iOS client so it can display the notification thread in the sidebar and deep-link to it.
1441
1618
 
1442
1619
  ### IPC Thread-Created Events
1443
1620
 
1444
1621
  Two IPC push events surface new threads in the macOS/iOS client sidebar:
1445
1622
 
1446
- - **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery creates a vellum conversation (strategy `start_new_conversation`). Payload: `{ conversationId, title, sourceEventName }`.
1623
+ - **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery **creates** a new vellum conversation (strategy `start_new_conversation`, `createdNewConversation: true`). **Not** emitted when a thread is reused. Payload: `{ conversationId, title, sourceEventName }`.
1447
1624
  - **`task_run_thread_created`** — Emitted by `work-item-runner.ts` when a task run creates a conversation. Payload: `{ conversationId, workItemId, title }`.
1448
1625
 
1449
1626
  All events follow the same pattern: the daemon creates a server-side conversation, persists an initial message, and broadcasts the IPC event so the macOS `ThreadManager` can create a visible thread in the sidebar.
1450
1627
 
1628
+ ### Thread Routing Decision Flow
1629
+
1630
+ The decision engine produces per-channel thread actions using a candidate-driven approach:
1631
+
1632
+ 1. **Candidate generation** (`thread-candidates.ts`): Queries recent notification-sourced conversations (24-hour window, up to 5 per channel) and enriches them with guardian context (pending request counts).
1633
+ 2. **LLM decision**: The candidate set is serialized into the system prompt. The LLM chooses `start_new` or `reuse_existing` (with a candidate `conversationId`) per channel.
1634
+ 3. **Strict validation** (`validateThreadActions`): Reuse targets must exist in the candidate set. Invalid targets are downgraded to `start_new`.
1635
+ 4. **Pairing execution**: `pairDeliveryWithConversation` executes the thread action — appending to an existing conversation on reuse, creating a new one otherwise.
1636
+ 5. **IPC gating**: `notification_thread_created` fires only on actual creation, not on reuse.
1637
+ 6. **Audit trail**: Thread actions are persisted in both `notification_decisions.validation_results` and `notification_deliveries` columns (`thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`).
1638
+
1639
+ ### Guardian Multi-Request Disambiguation in Reused Threads
1640
+
1641
+ When the decision engine routes multiple guardian questions to the same conversation (via `reuse_existing`), those questions share a single thread. The guardian disambiguates which question they are answering using **request-code prefixes**:
1642
+
1643
+ - **Single pending delivery**: Matched automatically (single-match fast path).
1644
+ - **Multiple pending deliveries**: The guardian must prefix their reply with the 6-char hex request code (e.g. `A1B2C3 yes, allow it`). Case-insensitive matching.
1645
+ - **No match**: A disambiguation message is sent listing all active request codes.
1646
+
1647
+ This invariant is enforced identically on mac/vellum (`session-process.ts`), Telegram, and SMS (`inbound-message-handler.ts`). All disambiguation messages are generated through the guardian action message composer (LLM with deterministic fallback).
1648
+
1451
1649
  ### Reminder Routing Metadata
1452
1650
 
1453
1651
  Reminders carry optional `routingIntent` (`single_channel` | `multi_channel` | `all_channels`) and free-form `routingHints` metadata. When a reminder fires, this metadata flows through the notification signal into a post-decision enforcement step (`enforceRoutingIntent()` in `decision-engine.ts`) that overrides the LLM's channel selection to match the requested coverage. This enables single-reminder fanout: one reminder can produce multi-channel delivery without duplicate reminders. See `assistant/docs/architecture/scheduling.md` for the full trigger-time data flow.
@@ -1470,8 +1668,9 @@ Connected channels are resolved at signal emission time: vellum is always includ
1470
1668
  | `assistant/src/notifications/emit-signal.ts` | Single entry point for all producers; orchestrates the full pipeline |
1471
1669
  | `assistant/src/notifications/decision-engine.ts` | LLM-based routing decisions with deterministic fallback |
1472
1670
  | `assistant/src/notifications/deterministic-checks.ts` | Hard invariant checks (dedupe, source-active suppression, channel availability) |
1473
- | `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC |
1474
- | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery based on channel strategy |
1671
+ | `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC (creation-only) |
1672
+ | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes thread reuse decisions |
1673
+ | `assistant/src/notifications/thread-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
1475
1674
  | `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via IPC with deep-link metadata |
1476
1675
  | `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
1477
1676
  | `assistant/src/notifications/adapters/sms.ts` | SMS adapter — POSTs to gateway `/deliver/sms` via Twilio Messages API |
@@ -1482,7 +1681,7 @@ Connected channels are resolved at signal emission time: vellum is always includ
1482
1681
  | `assistant/src/config/bundled-skills/messaging/tools/send-notification.ts` | Explicit producer tool for user-requested notifications; emits signals into the same routing pipeline |
1483
1682
  | `assistant/src/calls/guardian-dispatch.ts` | Guardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results |
1484
1683
 
1485
- **Audit trail (SQLite):** `notification_events` → `notification_decisions` → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`)
1684
+ **Audit trail (SQLite):** `notification_events` → `notification_decisions` (with `threadActions` in validation results) → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`)
1486
1685
 
1487
1686
  **Configuration:** `notifications.decisionModelIntent` in `config.json`.
1488
1687
 
package/Dockerfile CHANGED
@@ -89,7 +89,7 @@ RUN echo 'Dir::State "/data/dpkg";' > /etc/apt/apt.conf.d/99data-dir && \
89
89
  ENV PATH="/data/usr/bin:/data/usr/sbin:${PATH}"
90
90
  ENV LD_LIBRARY_PATH="/data/usr/lib:/data/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}"
91
91
 
92
- USER assistant
92
+ USER root
93
93
 
94
94
  EXPOSE 3001
95
95
 
package/README.md CHANGED
@@ -50,6 +50,12 @@ cp .env.example .env
50
50
  | `RUNTIME_GATEWAY_ORIGIN_SECRET` | No | — | Dedicated secret for the `X-Gateway-Origin` proof header on `/channels/inbound`. When not set, falls back to the bearer token. Both gateway and runtime must share the same value. |
51
51
  | `VELLUM_DAEMON_SOCKET` | No | `~/.vellum/vellum.sock` | Override the daemon socket path |
52
52
 
53
+ ## Update Bulletin
54
+
55
+ When a release includes relevant updates, the daemon materializes release notes from the bundled `src/config/templates/UPDATES.md` into `~/.vellum/workspace/UPDATES.md` on startup. The assistant uses judgment to surface updates to the user when relevant, and deletes the file when done.
56
+
57
+ **For release maintainers:** Update `assistant/src/config/templates/UPDATES.md` with release notes before each relevant release. Leave the template empty (or comment-only) for releases with no user/assistant-facing changes.
58
+
53
59
  ## Usage
54
60
 
55
61
  ### Start the daemon
@@ -262,7 +268,7 @@ The channel guardian service generates verification challenge instructions with
262
268
 
263
269
  ### Operator Notes
264
270
 
265
- - **Verify command formats:** The `/guardian_verify` command accepts both `/guardian_verify <code>` and `/guardian_verify@BotName <code>` (Telegram appends the bot username in group chats). The handler normalizes both formats automatically.
271
+ - **Verification input format:** Channel verification accepts a bare code reply only (6-digit numeric for identity-bound sessions; 64-char hex for unbound inbound/bootstrap compatibility).
266
272
  - **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the IPC request. Without it, the daemon returns `already_bound`. This prevents accidental guardian replacement.
267
273
  - **Takeover prevention:** Verification is rejected when an active binding exists for a different external user. Same-user re-verification is allowed.
268
274
 
@@ -274,9 +280,9 @@ This section documents the end-to-end flow from guardian verification through in
274
280
 
275
281
  Guardian verification establishes a cryptographic trust binding between a human identity and an `(assistantId, channel)` pair. The flow is:
276
282
 
277
- 1. **Challenge creation** — The owner initiates verification from the desktop UI, which sends a `guardian_verify` IPC message (action: `create_challenge`) to the daemon. The daemon generates a random secret (32-byte hex for text channels, 6-digit numeric for voice), hashes it with SHA-256, stores the hash with a 10-minute TTL, and returns the raw secret to the desktop.
278
- 2. **Secret sharing** — The desktop displays the secret and instructs the owner to send `/guardian_verify <secret>` to the bot on the target channel (e.g., Telegram).
279
- 3. **Verification** — When the message arrives at `/channels/inbound`, the handler intercepts the `/guardian_verify` prefix before normal message processing. It hashes the provided secret, looks up a matching pending challenge, validates expiry, and consumes the challenge (preventing replay).
283
+ 1. **Challenge creation** — The owner initiates verification from the desktop UI, which sends a guardian-verification IPC message (`create_challenge` action) to the daemon. The daemon generates a random secret (32-byte hex for unbound inbound/bootstrap sessions, 6-digit numeric for identity-bound sessions), hashes it with SHA-256, stores the hash with a 10-minute TTL, and returns the raw secret to the desktop.
284
+ 2. **Code sharing** — The desktop displays the code and instructs the owner to reply with that code in the target channel conversation (e.g., Telegram or SMS).
285
+ 3. **Verification** — When the message arrives at `/channels/inbound`, the handler intercepts valid verification-code replies before normal message processing. It hashes the provided code, looks up a matching pending challenge, validates expiry, and consumes the challenge (preventing replay).
280
286
  4. **Binding** — On success, any existing active binding for the `(assistantId, channel)` pair is revoked, and a new guardian binding is created with the verifier's `externalUserId` and `chatId`. The verifier receives a confirmation message.
281
287
 
282
288
  Rate limiting protects against brute-force attempts: 5 invalid attempts within 15 minutes trigger a 30-minute lockout per `(assistantId, channel, actor)` tuple. The same generic failure message is returned for both invalid codes and rate-limited attempts to avoid leaking state.
@@ -317,7 +323,7 @@ Guardian verification and ingress membership are complementary but independent s
317
323
  |------|---------|
318
324
  | `src/runtime/channel-guardian-service.ts` | Challenge lifecycle: `createVerificationChallenge`, `validateAndConsumeChallenge`, `getGuardianBinding`, `isGuardian` |
319
325
  | `src/runtime/guardian-context-resolver.ts` | Actor role classification: guardian / non-guardian / unverified_channel |
320
- | `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, `/guardian_verify` command intercept, escalation creation |
326
+ | `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, verification-code intercept, escalation creation |
321
327
  | `src/memory/ingress-member-store.ts` | Member CRUD: `findMember`, `upsertMember`, `revokeMember`, `blockMember` |
322
328
  | `src/memory/ingress-invite-store.ts` | Invite lifecycle: `createInvite`, `redeemInvite` (atomically creates member record) |
323
329
  | `src/memory/channel-guardian-store.ts` | Persistence for guardian bindings, verification challenges, and approval requests |
@@ -0,0 +1,274 @@
1
+ # HTTP Token Refresh Protocol
2
+
3
+ Design for how the daemon notifies clients of bearer token rotation and how clients recover from stale tokens.
4
+
5
+ ## Current State
6
+
7
+ The daemon's HTTP bearer token is resolved at startup and persisted to `~/.vellum/http-token` (mode 0600). The startup token resolution order is: (1) the `RUNTIME_PROXY_BEARER_TOKEN` env var if set, (2) the existing token read from `~/.vellum/http-token` if the file is readable and non-empty, (3) a newly generated random token as a last resort. Clients read this file at connection time:
8
+
9
+ - **macOS (local)**: Reads `~/.vellum/http-token` from disk via `resolveHttpTokenPath()` / `readHttpToken()`. Has direct filesystem access to the token file.
10
+ - **iOS (remote)**: Receives the bearer token during the QR-code pairing flow. The token is stored in the iOS Keychain and used for all subsequent HTTP/SSE requests.
11
+ - **Chrome extension**: User manually pastes the token from `~/.vellum/http-token` into the extension popup.
12
+
13
+ Token regeneration today (macOS Settings > Connect > Regenerate Bearer Token):
14
+ 1. macOS client writes a new random token to `~/.vellum/http-token`.
15
+ 2. macOS client kills the daemon process.
16
+ 3. The health monitor restarts the daemon, which reads the new token from disk.
17
+ 4. macOS client re-reads the token from disk on next health check.
18
+ 5. **iOS clients are broken** -- they still hold the old token and get 401s. The only recovery is to re-pair via QR code.
19
+
20
+ ## Problem
21
+
22
+ When the bearer token is rotated (manually or programmatically), remote clients (iOS, Chrome extension) have no way to learn about the new token. They receive 401 responses and cannot recover without manual re-configuration.
23
+
24
+ ## Design
25
+
26
+ ### 1. SSE Token Rotation Event
27
+
28
+ When the daemon detects that its bearer token has changed, it emits a `token_rotated` SSE event to all connected clients **before** the old token is invalidated. This gives clients a window to capture the new token and seamlessly reconnect.
29
+
30
+ **Event format** (delivered as an `AssistantEvent` envelope wrapping a new `ServerMessage` type):
31
+
32
+ ```typescript
33
+ // New ServerMessage variant
34
+ interface TokenRotatedMessage {
35
+ type: 'token_rotated';
36
+ newToken: string; // The replacement bearer token
37
+ expiresOldAt: number; // Unix timestamp (ms) -- old token stops working after this
38
+ }
39
+ ```
40
+
41
+ **Grace period**: The daemon accepts both old and new tokens for a configurable grace window (default: 30 seconds) after emitting the event. This gives slow clients time to process the event and switch tokens. After the grace period, only the new token is valid.
42
+
43
+ **Sequence diagram (routine rotation)**:
44
+
45
+ ```mermaid
46
+ sequenceDiagram
47
+ participant Trigger as Rotation Trigger
48
+ participant Daemon as Daemon
49
+ participant SSE as SSE Stream
50
+ participant Client as iOS / Chrome Client
51
+
52
+ Trigger->>Daemon: Rotate token (manual, API, periodic)
53
+ Daemon->>Daemon: Generate new token, write to ~/.vellum/http-token
54
+ Daemon->>Daemon: Enter grace period (accept old + new)
55
+ Daemon->>SSE: Emit token_rotated {newToken, expiresOldAt}
56
+ SSE->>Client: token_rotated event
57
+ Client->>Client: Store new token (Keychain / localStorage)
58
+ Client->>Client: Update Authorization header
59
+ Client->>Daemon: Reconnect SSE with new token
60
+ Note over Daemon: Grace period expires
61
+ Daemon->>Daemon: Reject old token (401)
62
+ ```
63
+
64
+ **Sequence diagram (revocation rotation)**:
65
+
66
+ ```mermaid
67
+ sequenceDiagram
68
+ participant Trigger as Security Event
69
+ participant Daemon as Daemon
70
+ participant SSE as SSE Stream
71
+ participant Client as iOS / Chrome Client
72
+
73
+ Trigger->>Daemon: Rotate token (revoke: true)
74
+ Daemon->>Daemon: Generate new token, immediately invalidate old token
75
+ Daemon->>SSE: Terminate all old-token connections
76
+ Daemon->>Daemon: Write new token to ~/.vellum/http-token
77
+ Note over SSE: No token_rotated event emitted
78
+ Client->>Daemon: Next request with old token → 401
79
+ Client->>Client: Trigger fallback recovery (re-pair / re-read / re-paste)
80
+ ```
81
+
82
+ ### 2. Client 401 Recovery (Stale Token Detection)
83
+
84
+ If a client misses the SSE event (network partition, app backgrounded, SSE disconnected at rotation time), it needs a fallback recovery mechanism.
85
+
86
+ **401 response handling**:
87
+
88
+ When a client receives a `401 Unauthorized` response:
89
+
90
+ 1. **macOS (local)**: Re-reads `~/.vellum/http-token` from disk. If the token differs from the in-memory token, updates and retries. This already works implicitly since macOS re-reads the token on most HTTP calls via `resolveLocalDaemonHTTPEndpoint()`.
91
+
92
+ 2. **iOS (remote)**: Cannot read the token file. Must re-pair via QR code. The 401 response triggers the client to surface a "Session expired -- re-pair required" UI prompt. This is the expected behavior when the SSE notification is missed.
93
+
94
+ 3. **Chrome extension**: Surfaces an error message directing the user to paste the new token.
95
+
96
+ **Retry logic**: Clients should retry at most once after a 401 before surfacing the error UI. This prevents retry storms during legitimate auth failures (wrong token, not just stale).
97
+
98
+ ### 3. Token Rotation Triggers
99
+
100
+ The token can be rotated via:
101
+
102
+ | Trigger | Description | Current | Proposed | Mode |
103
+ |---------|-------------|---------|----------|------|
104
+ | Manual (macOS Settings) | User clicks "Regenerate Bearer Token" | Yes (kills daemon) | Graceful rotation via daemon API | Routine |
105
+ | API endpoint | `POST /v1/auth/rotate-token` | No | New endpoint | Routine (default) or Revocation (`revoke: true`) |
106
+ | Periodic rotation | Automatic rotation on a configurable schedule | No | Future consideration | Routine |
107
+ | Security event | Forced rotation after suspicious activity | No | Future consideration | Revocation |
108
+
109
+ **`POST /v1/auth/rotate-token`** (new endpoint):
110
+
111
+ Allows programmatic token rotation without restarting the daemon.
112
+
113
+ Request body (optional): `{ "revoke": boolean }` (default: `false`).
114
+
115
+ **Routine mode** (`revoke: false`, default):
116
+ 1. Generates a new random token.
117
+ 2. Writes it to `~/.vellum/http-token`.
118
+ 3. Emits the `token_rotated` SSE event with grace period.
119
+ 4. Starts accepting both tokens during the grace period.
120
+ 5. After grace period, rejects the old token.
121
+
122
+ **Revocation mode** (`revoke: true`):
123
+ 1. Generates a new random token.
124
+ 2. Immediately invalidates the old token in memory (no grace period).
125
+ 3. Terminates all SSE connections authenticated with the old token.
126
+ 4. Writes the new token to `~/.vellum/http-token`.
127
+ 5. Does **not** emit `token_rotated` -- the new token is never sent to old-token sessions.
128
+ 6. Clients must recover via their platform-specific fallback (re-read from disk, re-pair, or re-paste).
129
+
130
+ This eliminates the current "kill and restart" approach for token rotation.
131
+
132
+ ### 4. Daemon-Side Implementation
133
+
134
+ **Grace period token validation**: During routine rotation, `verifyBearerToken()` accepts either the old or new token. The `RuntimeHttpServer` holds both tokens:
135
+
136
+ ```typescript
137
+ // Conceptual extension to RuntimeHttpServer
138
+ private currentToken: string;
139
+ private previousToken: string | null = null;
140
+ private graceDeadline: number | null = null;
141
+
142
+ // Modified auth check
143
+ private isValidToken(provided: string): boolean {
144
+ if (verifyBearerToken(provided, this.currentToken)) return true;
145
+ if (this.previousToken && this.graceDeadline && Date.now() < this.graceDeadline) {
146
+ return verifyBearerToken(provided, this.previousToken);
147
+ }
148
+ return false;
149
+ }
150
+
151
+ // Rotation has two ordering strategies depending on revoke mode:
152
+ // - Revocation: invalidate in-memory FIRST (security-critical), then persist.
153
+ // A disk write failure must never leave a compromised token valid.
154
+ // - Routine: persist to disk FIRST, then update in-memory state.
155
+ // A disk write failure aborts rotation — clients keep the old token
156
+ // rather than being locked out by an in-memory-only switch.
157
+ private rotateToken(revoke: boolean): string {
158
+ const newToken = generateToken();
159
+
160
+ if (revoke) {
161
+ // Revocation: invalidate the compromised token immediately.
162
+ // Even if the disk write below fails, the old token is gone from memory.
163
+ this.currentToken = newToken;
164
+ this.previousToken = null;
165
+ this.graceDeadline = null;
166
+ this.terminateOldTokenSSEConnections();
167
+ writeTokenToDisk(newToken);
168
+ } else {
169
+ // Routine: persist to disk first — if this throws, auth state is untouched
170
+ writeTokenToDisk(newToken);
171
+ this.previousToken = this.currentToken;
172
+ this.currentToken = newToken;
173
+ this.graceDeadline = Date.now() + GRACE_PERIOD_MS;
174
+ this.emitTokenRotatedEvent(newToken, this.graceDeadline);
175
+ }
176
+ return newToken;
177
+ }
178
+ ```
179
+
180
+ **Failure semantics — routine vs. revocation**:
181
+
182
+ The two rotation modes have deliberately different failure ordering to match their security requirements:
183
+
184
+ | | Routine (`revoke: false`) | Revocation (`revoke: true`) |
185
+ |---|---|---|
186
+ | **Order** | Persist to disk first, then update in-memory state | Update in-memory state first, then persist to disk |
187
+ | **Disk write failure** | Rotation aborts cleanly — in-memory auth state is untouched, clients keep working with the old token | Old token is already invalidated in memory; the API endpoint returns an error to the caller |
188
+ | **Rationale** | Availability: don't lock out clients if persistence fails | Security: a potentially compromised token must never remain valid, even briefly |
189
+
190
+ **Revocation disk-write failure in detail**: If `writeTokenToDisk` throws after the in-memory switch during revocation, the system enters a degraded state:
191
+
192
+ 1. **In-memory state**: `currentToken` holds the new (unpersisted) token. The old token is rejected. All old-token SSE connections have been terminated.
193
+ 2. **Disk state**: `~/.vellum/http-token` still contains the old (now-invalid) token.
194
+ 3. **API response**: The `POST /v1/auth/rotate-token` endpoint returns an error indicating the persistence failure. The response body includes the new token so the caller can manually persist or distribute it if needed.
195
+ 4. **Client impact by platform**:
196
+ - **macOS**: Re-reading the token file yields the stale old token, which is rejected (401). Recovery requires a daemon restart (which generates a fresh token and persists it) or a successful retry of the rotation API call.
197
+ - **iOS**: Already disconnected (old-token SSE terminated). Cannot recover until the daemon restarts or the rotation is retried successfully, at which point re-pairing is required.
198
+ - **Chrome extension**: Same as iOS — the pasted token is stale and rejected.
199
+ 5. **Daemon restart recovery**: A daemon restart does **not** automatically heal this state. At startup, the daemon first checks for the `RUNTIME_PROXY_BEARER_TOKEN` env var, then tries to read the existing token from `~/.vellum/http-token`, and only generates a new random token if both are unavailable (see `assistant/src/daemon/lifecycle.ts`, lines 110-124). In the degraded state described here — where the disk still holds the old (now-invalid) token — a restart would reload that stale token, making it the active bearer token again. To actually recover, the operator must either: (a) manually delete or overwrite `~/.vellum/http-token` before restarting the daemon, (b) set `RUNTIME_PROXY_BEARER_TOKEN` to a known-good value, or (c) successfully retry the `POST /v1/auth/rotate-token` endpoint while the daemon is still running with the new in-memory token.
200
+ 6. **Why this is acceptable**: Revocation is a security-critical operation triggered when the old token is suspected compromised. The invariant — "a compromised token must not remain valid" — takes precedence over client convenience. The degraded state requires manual intervention but disk write failures are rare in practice (permissions, disk full), and the API response includes the new token so the caller can retry or manually persist it.
201
+
202
+ **SSE event emission** (routine rotation only): The `token_rotated` event is published to `assistantEventHub` as a `ServerMessage`, reaching all connected SSE subscribers across all conversations. This event is never emitted during revocation rotations.
203
+
204
+ ### 5. iOS Client Implementation
205
+
206
+ **SSE event handler** (in `HTTPTransport`):
207
+
208
+ ```swift
209
+ // In parseSSEData, handle the new message type
210
+ case .tokenRotated(let msg):
211
+ // Persist the new token to Keychain
212
+ DaemonConfigStore.shared.updateBearerToken(msg.newToken)
213
+ // Update in-memory token
214
+ self.bearerToken = msg.newToken
215
+ // Reconnect SSE with the new token
216
+ self.stopSSE()
217
+ self.startSSE()
218
+ ```
219
+
220
+ **401 response handler**:
221
+
222
+ ```swift
223
+ // In any HTTP request that receives 401
224
+ if http.statusCode == 401 {
225
+ // Token is stale and we missed the rotation event
226
+ // Surface re-pairing UI
227
+ onMessage?(.sessionError(SessionErrorMessage(
228
+ sessionId: sessionId,
229
+ code: .authenticationRequired,
230
+ userMessage: "Session expired. Please re-pair your device.",
231
+ retryable: false
232
+ )))
233
+ }
234
+ ```
235
+
236
+ ### 6. Security Considerations
237
+
238
+ - **Rotation modes**: Token rotation has two distinct modes with different security requirements:
239
+
240
+ 1. **Routine rotation** (manual refresh, periodic schedule): The old token is not compromised -- the goal is seamless credential rollover. The SSE `token_rotated` event delivers the new token to connected clients, and the grace period allows them to transition. This is safe because the SSE channel is authenticated, and any session holding the old token is a legitimate client.
241
+
242
+ 2. **Revocation rotation** (security event, suspected compromise): The old token may be in the hands of an attacker. In this mode, the daemon **must not** push the replacement token to old-token SSE sessions -- doing so would hand the new credential to the very sessions being revoked. Instead:
243
+ - The daemon immediately invalidates the old token (no grace period).
244
+ - All SSE connections authenticated with the old token are terminated.
245
+ - The `POST /v1/auth/rotate-token` endpoint accepts an optional `revoke: true` flag to select this mode.
246
+ - Legitimate clients recover via their fallback path: macOS re-reads `~/.vellum/http-token` from disk; iOS prompts for re-pairing; Chrome extension prompts for a new token.
247
+
248
+ The `token_rotated` SSE event is only emitted during routine rotations. The rotation trigger determines the mode.
249
+
250
+ - **Grace period length**: 30 seconds is long enough for clients to process the event but short enough to limit the window where both tokens are valid. Only applies to routine rotations.
251
+ - **No token in logs**: The `token_rotated` event payload must be excluded from any server-side event logging. Use the existing log-redaction patterns.
252
+ - **Constant-time comparison**: The existing `verifyBearerToken()` using `timingSafeEqual` continues to be used for both old and new token checks during the grace period.
253
+
254
+ ### 7. Migration Path
255
+
256
+ This design is additive and backward-compatible:
257
+
258
+ 1. **Phase 1**: Add `POST /v1/auth/rotate-token` endpoint and `token_rotated` SSE event to the daemon. Update macOS Settings to call the API endpoint instead of kill-and-restart.
259
+ 2. **Phase 2**: Add `token_rotated` handler to `HTTPTransport.swift` (shared between macOS and iOS). Add 401 retry-once logic.
260
+ 3. **Phase 3** (future): Add periodic rotation and security-event-triggered rotation.
261
+
262
+ Clients that do not understand the `token_rotated` event will simply ignore it (SSE events with unknown types are safe to skip). They will eventually get 401s after the grace period and fall back to their existing recovery path (re-read from disk for macOS, re-pair for iOS).
263
+
264
+ ## Key Files
265
+
266
+ | File | Role |
267
+ |------|------|
268
+ | `assistant/src/runtime/http-server.ts` | Auth check, grace period logic, rotation endpoint |
269
+ | `assistant/src/runtime/middleware/auth.ts` | `verifyBearerToken()` -- constant-time token comparison |
270
+ | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` envelope, SSE framing |
271
+ | `assistant/src/daemon/lifecycle.ts` | Token generation and persistence at startup |
272
+ | `clients/shared/IPC/HTTPDaemonClient.swift` | `HTTPTransport` -- SSE stream, 401 handling |
273
+ | `clients/shared/IPC/DaemonClient.swift` | `readHttpToken()`, `resolveHttpTokenPath()` |
274
+ | `clients/macos/.../SettingsConnectTab.swift` | Manual token regeneration UI |