@vellumai/assistant 0.5.6 → 0.5.8

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 (442) hide show
  1. package/.env.example +16 -2
  2. package/ARCHITECTURE.md +6 -75
  3. package/Dockerfile +3 -2
  4. package/README.md +0 -2
  5. package/bun.lock +0 -414
  6. package/docker-entrypoint.sh +9 -0
  7. package/docs/architecture/keychain-broker.md +45 -240
  8. package/docs/architecture/memory.md +13 -11
  9. package/docs/architecture/security.md +0 -17
  10. package/docs/credential-execution-service.md +2 -2
  11. package/node_modules/@vellumai/ces-contracts/package.json +1 -0
  12. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  13. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  14. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  15. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  16. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +120 -1
  17. package/node_modules/@vellumai/credential-storage/package.json +1 -0
  18. package/node_modules/@vellumai/egress-proxy/package.json +1 -0
  19. package/package.json +2 -3
  20. package/src/__tests__/actor-token-service.test.ts +0 -114
  21. package/src/__tests__/approval-cascade.test.ts +0 -1
  22. package/src/__tests__/assistant-feature-flags-integration.test.ts +30 -29
  23. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  24. package/src/__tests__/browser-skill-endstate.test.ts +6 -5
  25. package/src/__tests__/btw-routes.test.ts +0 -39
  26. package/src/__tests__/call-controller.test.ts +0 -1
  27. package/src/__tests__/call-domain.test.ts +0 -128
  28. package/src/__tests__/ces-rpc-credential-backend.test.ts +199 -0
  29. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  30. package/src/__tests__/channel-approval-routes.test.ts +0 -5
  31. package/src/__tests__/channel-readiness-service.test.ts +1 -60
  32. package/src/__tests__/checker.test.ts +4 -2
  33. package/src/__tests__/cli-command-risk-guard.test.ts +112 -0
  34. package/src/__tests__/config-schema-cmd.test.ts +0 -2
  35. package/src/__tests__/config-schema.test.ts +3 -1
  36. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  37. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  38. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  39. package/src/__tests__/conversation-attention-telegram.test.ts +0 -5
  40. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  41. package/src/__tests__/conversation-error.test.ts +15 -1
  42. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  43. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  44. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  45. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  46. package/src/__tests__/conversation-queue.test.ts +0 -1
  47. package/src/__tests__/conversation-skill-tools.test.ts +0 -54
  48. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  49. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  50. package/src/__tests__/conversation-title-service.test.ts +87 -0
  51. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  52. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  53. package/src/__tests__/credential-execution-client.test.ts +5 -2
  54. package/src/__tests__/credential-execution-feature-gates.test.ts +59 -30
  55. package/src/__tests__/credential-execution-managed-contract.test.ts +35 -20
  56. package/src/__tests__/credential-security-e2e.test.ts +1 -67
  57. package/src/__tests__/credential-security-invariants.test.ts +6 -50
  58. package/src/__tests__/credentials-cli.test.ts +82 -3
  59. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  60. package/src/__tests__/db-migration-rollback.test.ts +2015 -1
  61. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  62. package/src/__tests__/docker-signing-key-bootstrap.test.ts +34 -143
  63. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -4
  64. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  65. package/src/__tests__/guardian-routing-state.test.ts +0 -5
  66. package/src/__tests__/host-shell-tool.test.ts +6 -7
  67. package/src/__tests__/http-user-message-parity.test.ts +3 -103
  68. package/src/__tests__/inbound-invite-redemption.test.ts +0 -4
  69. package/src/__tests__/inline-skill-load-permissions.test.ts +6 -8
  70. package/src/__tests__/intent-routing.test.ts +0 -13
  71. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +178 -0
  72. package/src/__tests__/journal-context.test.ts +335 -0
  73. package/src/__tests__/keychain-broker-client.test.ts +161 -22
  74. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  75. package/src/__tests__/memory-jobs-worker-backoff.test.ts +150 -0
  76. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  77. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  78. package/src/__tests__/memory-regressions.test.ts +408 -363
  79. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  80. package/src/__tests__/migration-export-http.test.ts +2 -2
  81. package/src/__tests__/migration-import-commit-http.test.ts +2 -2
  82. package/src/__tests__/migration-import-preflight-http.test.ts +2 -2
  83. package/src/__tests__/migration-validate-http.test.ts +2 -2
  84. package/src/__tests__/non-member-access-request.test.ts +2 -7
  85. package/src/__tests__/notification-decision-fallback.test.ts +4 -0
  86. package/src/__tests__/notification-decision-identity.test.ts +4 -0
  87. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  88. package/src/__tests__/oauth-cli.test.ts +5 -1
  89. package/src/__tests__/permission-types.test.ts +1 -0
  90. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  91. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  92. package/src/__tests__/provider-managed-proxy-integration.test.ts +5 -6
  93. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  94. package/src/__tests__/qdrant-manager.test.ts +28 -2
  95. package/src/__tests__/registry.test.ts +0 -6
  96. package/src/__tests__/relay-server.test.ts +1 -2
  97. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -4
  98. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  99. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  100. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -4
  101. package/src/__tests__/secure-keys.test.ts +95 -272
  102. package/src/__tests__/shell-identity.test.ts +96 -6
  103. package/src/__tests__/skill-feature-flags-integration.test.ts +22 -14
  104. package/src/__tests__/skill-feature-flags.test.ts +46 -45
  105. package/src/__tests__/skill-load-feature-flag.test.ts +7 -10
  106. package/src/__tests__/skill-load-inline-command.test.ts +8 -12
  107. package/src/__tests__/skill-load-inline-includes.test.ts +6 -10
  108. package/src/__tests__/skill-load-tool.test.ts +0 -2
  109. package/src/__tests__/skill-memory.test.ts +17 -3
  110. package/src/__tests__/skill-projection-feature-flag.test.ts +33 -29
  111. package/src/__tests__/skills.test.ts +0 -2
  112. package/src/__tests__/slack-inbound-verification.test.ts +0 -4
  113. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  114. package/src/__tests__/stt-hints.test.ts +437 -0
  115. package/src/__tests__/suggestion-routes.test.ts +1 -32
  116. package/src/__tests__/system-prompt.test.ts +0 -1
  117. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  118. package/src/__tests__/tool-executor-shell-integration.test.ts +5 -3
  119. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -5
  120. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -4
  121. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  122. package/src/__tests__/update-bulletin.test.ts +0 -2
  123. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +6 -9
  124. package/src/__tests__/voice-quality.test.ts +58 -0
  125. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -7
  126. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +252 -0
  127. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +220 -0
  128. package/src/__tests__/workspace-migration-down-functions.test.ts +1009 -0
  129. package/src/__tests__/workspace-migrations-runner.test.ts +114 -0
  130. package/src/acp/agent-process.ts +9 -1
  131. package/src/agent/loop.ts +1 -1
  132. package/src/approvals/guardian-request-resolvers.ts +164 -38
  133. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  134. package/src/calls/audio-store.test.ts +97 -0
  135. package/src/calls/audio-store.ts +205 -0
  136. package/src/calls/call-controller.ts +90 -8
  137. package/src/calls/call-domain.ts +3 -0
  138. package/src/calls/call-store.ts +10 -3
  139. package/src/calls/fish-audio-client.ts +129 -0
  140. package/src/calls/relay-server.ts +27 -0
  141. package/src/calls/stt-hints.ts +189 -0
  142. package/src/calls/tts-text-sanitizer.ts +61 -0
  143. package/src/calls/twilio-routes.ts +34 -5
  144. package/src/calls/types.ts +1 -0
  145. package/src/calls/voice-ingress-preflight.ts +0 -42
  146. package/src/calls/voice-quality.ts +38 -5
  147. package/src/calls/voice-session-bridge.ts +7 -12
  148. package/src/cli/commands/avatar.ts +2 -2
  149. package/src/cli/commands/config.ts +1 -4
  150. package/src/cli/commands/credentials.ts +128 -82
  151. package/src/cli/commands/doctor.ts +2 -2
  152. package/src/cli/commands/keys.ts +7 -7
  153. package/src/cli/commands/memory.ts +1 -1
  154. package/src/cli/commands/oauth/connections.ts +11 -29
  155. package/src/cli/commands/oauth/index.ts +7 -0
  156. package/src/cli/commands/oauth/platform.ts +525 -0
  157. package/src/cli/commands/platform.ts +3 -3
  158. package/src/cli/lib/daemon-credential-client.ts +284 -0
  159. package/src/cli.ts +1 -1
  160. package/src/config/assistant-feature-flags.ts +186 -5
  161. package/src/config/bundled-skills/AGENTS.md +34 -0
  162. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  163. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  164. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  165. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  166. package/src/config/bundled-skills/phone-calls/TOOLS.json +4 -0
  167. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  168. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  169. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  170. package/src/config/bundled-skills/settings/TOOLS.json +47 -2
  171. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  172. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  173. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +42 -0
  174. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  175. package/src/config/bundled-tool-registry.ts +5 -11
  176. package/src/config/defaults.ts +0 -2
  177. package/src/config/env-registry.ts +5 -5
  178. package/src/config/env.ts +21 -14
  179. package/src/config/feature-flag-registry.json +49 -9
  180. package/src/config/loader.ts +106 -42
  181. package/src/config/schema.ts +9 -29
  182. package/src/config/schemas/calls.ts +30 -0
  183. package/src/config/schemas/fish-audio.ts +39 -0
  184. package/src/config/schemas/inference.ts +2 -2
  185. package/src/config/schemas/journal.ts +16 -0
  186. package/src/config/schemas/memory-processing.ts +2 -2
  187. package/src/config/schemas/security.ts +0 -4
  188. package/src/config/types.ts +1 -1
  189. package/src/contacts/contact-store.ts +39 -0
  190. package/src/contacts/types.ts +2 -0
  191. package/src/credential-execution/approval-bridge.ts +1 -0
  192. package/src/credential-execution/executable-discovery.ts +28 -4
  193. package/src/credential-execution/feature-gates.ts +16 -0
  194. package/src/credential-execution/process-manager.ts +38 -0
  195. package/src/credential-execution/startup-timeout.ts +36 -0
  196. package/src/daemon/approval-generators.ts +3 -9
  197. package/src/daemon/assistant-attachments.ts +9 -0
  198. package/src/daemon/config-watcher.ts +5 -0
  199. package/src/daemon/conversation-error.ts +13 -1
  200. package/src/daemon/conversation-memory.ts +1 -2
  201. package/src/daemon/conversation-process.ts +18 -1
  202. package/src/daemon/conversation-surfaces.ts +30 -1
  203. package/src/daemon/conversation-tool-setup.ts +0 -105
  204. package/src/daemon/conversation.ts +21 -1
  205. package/src/daemon/guardian-action-generators.ts +3 -9
  206. package/src/daemon/handlers/config-vercel.ts +92 -0
  207. package/src/daemon/handlers/skills.ts +2 -15
  208. package/src/daemon/install-symlink.ts +195 -0
  209. package/src/daemon/lifecycle.ts +234 -51
  210. package/src/daemon/message-types/conversations.ts +4 -4
  211. package/src/daemon/message-types/diagnostics.ts +3 -22
  212. package/src/daemon/message-types/messages.ts +0 -2
  213. package/src/daemon/message-types/upgrades.ts +8 -0
  214. package/src/daemon/server.ts +32 -95
  215. package/src/events/domain-events.ts +2 -1
  216. package/src/inbound/platform-callback-registration.ts +3 -3
  217. package/src/instrument.ts +8 -5
  218. package/src/memory/app-store.ts +31 -0
  219. package/src/memory/conversation-title-service.ts +50 -1
  220. package/src/memory/db-init.ts +16 -0
  221. package/src/memory/indexer.ts +19 -10
  222. package/src/memory/items-extractor.ts +328 -321
  223. package/src/memory/job-handlers/conversation-starters.ts +4 -1
  224. package/src/memory/job-handlers/summarization.ts +26 -16
  225. package/src/memory/jobs-store.ts +63 -6
  226. package/src/memory/jobs-worker.ts +31 -7
  227. package/src/memory/journal-memory.ts +214 -0
  228. package/src/memory/migrations/001-job-deferrals.ts +19 -0
  229. package/src/memory/migrations/004-entity-relation-dedup.ts +10 -0
  230. package/src/memory/migrations/005-fingerprint-scope-unique.ts +76 -0
  231. package/src/memory/migrations/006-scope-salted-fingerprints.ts +50 -0
  232. package/src/memory/migrations/007-assistant-id-to-self.ts +10 -0
  233. package/src/memory/migrations/008-remove-assistant-id-columns.ts +34 -0
  234. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +26 -0
  235. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +10 -0
  236. package/src/memory/migrations/015-drop-active-search-index.ts +17 -0
  237. package/src/memory/migrations/019-notification-tables-schema-migration.ts +12 -0
  238. package/src/memory/migrations/020-rename-macos-ios-channel-to-vellum.ts +121 -0
  239. package/src/memory/migrations/024-embedding-vector-blob.ts +74 -0
  240. package/src/memory/migrations/026a-embeddings-nullable-vector-json.ts +82 -0
  241. package/src/memory/migrations/036-normalize-phone-identities.ts +11 -0
  242. package/src/memory/migrations/116-messages-fts.ts +106 -1
  243. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +52 -0
  244. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +77 -0
  245. package/src/memory/migrations/134-contacts-notes-column.ts +13 -0
  246. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +20 -0
  247. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -0
  248. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +13 -0
  249. package/src/memory/migrations/141-rename-verification-table.ts +54 -0
  250. package/src/memory/migrations/142-rename-verification-session-id-column.ts +25 -0
  251. package/src/memory/migrations/143-rename-guardian-verification-values.ts +35 -0
  252. package/src/memory/migrations/144-rename-voice-to-phone.ts +136 -0
  253. package/src/memory/migrations/145-drop-accounts-table.ts +32 -0
  254. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +14 -1
  255. package/src/memory/migrations/148-drop-reminders-table.ts +35 -1
  256. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +69 -1
  257. package/src/memory/migrations/162-guardian-timestamps-epoch-ms.ts +290 -0
  258. package/src/memory/migrations/169-rename-gmail-provider-key-to-google.ts +51 -1
  259. package/src/memory/migrations/174-rename-thread-starters-table.ts +47 -1
  260. package/src/memory/migrations/176-drop-capability-card-state.ts +13 -0
  261. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +16 -0
  262. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +28 -1
  263. package/src/memory/migrations/190-call-session-skip-disclosure.ts +15 -0
  264. package/src/memory/migrations/191-backfill-audio-attachment-mime-types.ts +64 -0
  265. package/src/memory/migrations/192-contacts-user-file-column.ts +15 -0
  266. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  267. package/src/memory/migrations/index.ts +5 -0
  268. package/src/memory/migrations/registry.ts +98 -0
  269. package/src/memory/migrations/validate-migration-state.ts +137 -11
  270. package/src/memory/qdrant-circuit-breaker.ts +9 -0
  271. package/src/memory/qdrant-manager.ts +64 -7
  272. package/src/memory/retriever.test.ts +37 -25
  273. package/src/memory/retriever.ts +24 -49
  274. package/src/memory/schema/calls.ts +1 -0
  275. package/src/memory/schema/contacts.ts +1 -0
  276. package/src/memory/schema/memory-core.ts +2 -0
  277. package/src/memory/search/formatting.ts +7 -44
  278. package/src/memory/search/staleness.ts +4 -0
  279. package/src/memory/search/tier-classifier.ts +10 -2
  280. package/src/memory/search/types.ts +2 -5
  281. package/src/memory/task-memory-cleanup.ts +4 -3
  282. package/src/notifications/adapters/slack.ts +168 -6
  283. package/src/notifications/broadcaster.ts +1 -0
  284. package/src/notifications/copy-composer.ts +59 -2
  285. package/src/notifications/decision-engine.ts +4 -1
  286. package/src/notifications/signal.ts +2 -0
  287. package/src/notifications/types.ts +2 -0
  288. package/src/oauth/connection-resolver.ts +6 -4
  289. package/src/permissions/checker.ts +0 -38
  290. package/src/permissions/shell-identity.ts +76 -22
  291. package/src/permissions/types.ts +4 -2
  292. package/src/platform/client.ts +35 -7
  293. package/src/prompts/journal-context.ts +133 -0
  294. package/src/prompts/persona-resolver.ts +194 -0
  295. package/src/prompts/system-prompt.ts +44 -4
  296. package/src/prompts/templates/SOUL.md +10 -0
  297. package/src/prompts/templates/users/default.md +1 -0
  298. package/src/providers/provider-send-message.ts +3 -32
  299. package/src/providers/registry.ts +29 -179
  300. package/src/providers/types.ts +1 -1
  301. package/src/runtime/access-request-helper.ts +4 -0
  302. package/src/runtime/auth/__tests__/credential-service.test.ts +0 -1
  303. package/src/runtime/auth/__tests__/external-assistant-id.test.ts +13 -68
  304. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  305. package/src/runtime/auth/external-assistant-id.ts +13 -59
  306. package/src/runtime/auth/route-policy.ts +17 -1
  307. package/src/runtime/auth/token-service.ts +43 -138
  308. package/src/runtime/channel-readiness-service.ts +1 -16
  309. package/src/runtime/gateway-client.ts +47 -4
  310. package/src/runtime/guardian-decision-types.ts +45 -4
  311. package/src/runtime/http-server.ts +31 -3
  312. package/src/runtime/middleware/error-handler.ts +1 -9
  313. package/src/runtime/routes/access-request-decision.ts +2 -2
  314. package/src/runtime/routes/app-management-routes.ts +2 -1
  315. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  316. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  317. package/src/runtime/routes/audio-routes.ts +40 -0
  318. package/src/runtime/routes/btw-routes.ts +0 -17
  319. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  320. package/src/runtime/routes/conversation-query-routes.ts +63 -1
  321. package/src/runtime/routes/conversation-routes.ts +4 -44
  322. package/src/runtime/routes/debug-routes.ts +12 -9
  323. package/src/runtime/routes/diagnostics-routes.ts +1 -477
  324. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  325. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  326. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  327. package/src/runtime/routes/identity-routes.ts +19 -30
  328. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  329. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  330. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  331. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +4 -33
  332. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +1 -1
  333. package/src/runtime/routes/integrations/twilio.ts +52 -10
  334. package/src/runtime/routes/integrations/vercel.ts +89 -0
  335. package/src/runtime/routes/log-export-routes.ts +5 -0
  336. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  337. package/src/runtime/routes/memory-item-routes.ts +46 -14
  338. package/src/runtime/routes/migration-rollback-routes.ts +209 -0
  339. package/src/runtime/routes/migration-routes.ts +17 -1
  340. package/src/runtime/routes/notification-routes.ts +58 -0
  341. package/src/runtime/routes/schedule-routes.ts +65 -0
  342. package/src/runtime/routes/secret-routes.ts +141 -10
  343. package/src/runtime/routes/settings-routes.ts +41 -1
  344. package/src/runtime/routes/tts-routes.ts +96 -0
  345. package/src/runtime/routes/upgrade-broadcast-routes.ts +26 -2
  346. package/src/runtime/routes/workspace-commit-routes.ts +62 -0
  347. package/src/runtime/routes/workspace-routes.test.ts +22 -1
  348. package/src/runtime/routes/workspace-routes.ts +1 -1
  349. package/src/runtime/routes/workspace-utils.ts +86 -2
  350. package/src/security/ces-credential-client.ts +75 -29
  351. package/src/security/ces-rpc-credential-backend.ts +86 -0
  352. package/src/security/credential-backend.ts +22 -92
  353. package/src/security/keychain-broker-client.ts +10 -2
  354. package/src/security/secure-keys.ts +113 -115
  355. package/src/skills/catalog-install.ts +6 -32
  356. package/src/skills/skill-memory.ts +1 -0
  357. package/src/subagent/manager.ts +2 -5
  358. package/src/telemetry/usage-telemetry-reporter.ts +4 -2
  359. package/src/tools/acp/spawn.ts +78 -1
  360. package/src/tools/calls/call-start.ts +1 -0
  361. package/src/tools/credentials/vault.ts +5 -3
  362. package/src/tools/executor.ts +0 -4
  363. package/src/tools/memory/definitions.ts +3 -2
  364. package/src/tools/memory/handlers.ts +10 -7
  365. package/src/tools/network/script-proxy/session-manager.ts +19 -4
  366. package/src/tools/network/web-fetch.ts +3 -1
  367. package/src/tools/skills/execute.ts +1 -1
  368. package/src/tools/terminal/safe-env.ts +1 -0
  369. package/src/tools/types.ts +0 -8
  370. package/src/util/browser.ts +15 -0
  371. package/src/util/errors.ts +0 -12
  372. package/src/util/platform.ts +4 -51
  373. package/src/workspace/git-service.ts +5 -2
  374. package/src/workspace/migrations/001-avatar-rename.ts +15 -0
  375. package/src/workspace/migrations/003-seed-device-id.ts +17 -1
  376. package/src/workspace/migrations/004-extract-collect-usage-data.ts +33 -0
  377. package/src/workspace/migrations/005-add-send-diagnostics.ts +3 -0
  378. package/src/workspace/migrations/006-services-config.ts +49 -0
  379. package/src/workspace/migrations/007-web-search-provider-rename.ts +27 -0
  380. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +3 -0
  381. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +4 -0
  382. package/src/workspace/migrations/010-app-dir-rename.ts +78 -0
  383. package/src/workspace/migrations/011-backfill-installation-id.ts +11 -0
  384. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +44 -0
  385. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +5 -0
  386. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +153 -0
  387. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +156 -0
  388. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +150 -0
  389. package/src/workspace/migrations/017-seed-persona-dirs.ts +96 -0
  390. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  391. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  392. package/src/workspace/migrations/migrate-to-workspace-volume.ts +27 -5
  393. package/src/workspace/migrations/registry.ts +12 -0
  394. package/src/workspace/migrations/runner.ts +106 -2
  395. package/src/workspace/migrations/types.ts +4 -0
  396. package/src/workspace/provider-commit-message-generator.ts +12 -21
  397. package/src/__tests__/claude-code-skill-regression.test.ts +0 -206
  398. package/src/__tests__/claude-code-tool-profiles.test.ts +0 -99
  399. package/src/__tests__/diagnostics-export.test.ts +0 -288
  400. package/src/__tests__/local-gateway-health.test.ts +0 -209
  401. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  402. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  403. package/src/__tests__/secret-ingress-handler.test.ts +0 -120
  404. package/src/__tests__/swarm-conversation-integration.test.ts +0 -358
  405. package/src/__tests__/swarm-dag-pathological.test.ts +0 -547
  406. package/src/__tests__/swarm-orchestrator.test.ts +0 -463
  407. package/src/__tests__/swarm-plan-validator.test.ts +0 -384
  408. package/src/__tests__/swarm-recursion.test.ts +0 -197
  409. package/src/__tests__/swarm-router-planner.test.ts +0 -234
  410. package/src/__tests__/swarm-tool.test.ts +0 -185
  411. package/src/__tests__/swarm-worker-backend.test.ts +0 -144
  412. package/src/__tests__/swarm-worker-runner.test.ts +0 -288
  413. package/src/commands/__tests__/cc-command-registry.test.ts +0 -396
  414. package/src/commands/cc-command-registry.ts +0 -248
  415. package/src/config/bundled-skills/claude-code/SKILL.md +0 -53
  416. package/src/config/bundled-skills/claude-code/TOOLS.json +0 -47
  417. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +0 -12
  418. package/src/config/bundled-skills/orchestration/SKILL.md +0 -33
  419. package/src/config/bundled-skills/orchestration/TOOLS.json +0 -35
  420. package/src/config/bundled-skills/orchestration/tools/swarm-delegate.ts +0 -12
  421. package/src/config/schemas/swarm.ts +0 -82
  422. package/src/logfire.ts +0 -135
  423. package/src/memory/search/lexical.ts +0 -48
  424. package/src/providers/failover.ts +0 -186
  425. package/src/runtime/local-gateway-health.ts +0 -275
  426. package/src/security/secret-ingress.ts +0 -68
  427. package/src/swarm/backend-claude-code.ts +0 -225
  428. package/src/swarm/checkpoint.ts +0 -137
  429. package/src/swarm/graph-utils.ts +0 -53
  430. package/src/swarm/index.ts +0 -55
  431. package/src/swarm/limits.ts +0 -66
  432. package/src/swarm/orchestrator.ts +0 -424
  433. package/src/swarm/plan-validator.ts +0 -117
  434. package/src/swarm/router-planner.ts +0 -162
  435. package/src/swarm/router-prompts.ts +0 -39
  436. package/src/swarm/synthesizer.ts +0 -81
  437. package/src/swarm/types.ts +0 -72
  438. package/src/swarm/worker-backend.ts +0 -131
  439. package/src/swarm/worker-prompts.ts +0 -80
  440. package/src/swarm/worker-runner.ts +0 -170
  441. package/src/tools/claude-code/claude-code.ts +0 -610
  442. package/src/tools/swarm/delegate.ts +0 -205
@@ -10,18 +10,60 @@
10
10
  * migration system detects and handles it gracefully.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto";
13
14
  import { Database } from "bun:sqlite";
14
- import { describe, expect, test } from "bun:test";
15
+ import { afterEach, describe, expect, test } from "bun:test";
15
16
 
16
17
  import { drizzle } from "drizzle-orm/bun-sqlite";
17
18
 
18
19
  import { getSqliteFrom } from "../memory/db-connection.js";
20
+ import { downJobDeferrals } from "../memory/migrations/001-job-deferrals.js";
21
+ import { downMemoryEntityRelationDedup } from "../memory/migrations/004-entity-relation-dedup.js";
22
+ import { downMemoryItemsFingerprintScopeUnique } from "../memory/migrations/005-fingerprint-scope-unique.js";
23
+ import { downMemoryItemsScopeSaltedFingerprints } from "../memory/migrations/006-scope-salted-fingerprints.js";
24
+ import { downAssistantIdToSelf } from "../memory/migrations/007-assistant-id-to-self.js";
25
+ import { downRemoveAssistantIdColumns } from "../memory/migrations/008-remove-assistant-id-columns.js";
26
+ import { downLlmUsageEventsDropAssistantId } from "../memory/migrations/009-llm-usage-events-drop-assistant-id.js";
27
+ import { downBackfillInboxThreadState } from "../memory/migrations/014-backfill-inbox-thread-state.js";
28
+ import { downDropActiveSearchIndex } from "../memory/migrations/015-drop-active-search-index.js";
29
+ import { downNotificationTablesSchema } from "../memory/migrations/019-notification-tables-schema-migration.js";
30
+ import { downRenameChannelToVellum } from "../memory/migrations/020-rename-macos-ios-channel-to-vellum.js";
31
+ import { downEmbeddingVectorBlob } from "../memory/migrations/024-embedding-vector-blob.js";
32
+ import { downEmbeddingsNullableVectorJson } from "../memory/migrations/026a-embeddings-nullable-vector-json.js";
33
+ import { downNormalizePhoneIdentities } from "../memory/migrations/036-normalize-phone-identities.js";
34
+ import { downBackfillGuardianPrincipalId } from "../memory/migrations/126-backfill-guardian-principal-id.js";
35
+ import { downGuardianPrincipalIdNotNull } from "../memory/migrations/127-guardian-principal-id-not-null.js";
36
+ import { downContactsNotesColumn } from "../memory/migrations/134-contacts-notes-column.js";
37
+ import { downBackfillContactInteractionStats } from "../memory/migrations/135-backfill-contact-interaction-stats.js";
38
+ import { downDropAssistantIdColumns } from "../memory/migrations/136-drop-assistant-id-columns.js";
39
+ import { downBackfillUsageCacheAccounting } from "../memory/migrations/140-backfill-usage-cache-accounting.js";
40
+ import { downRenameVerificationTable } from "../memory/migrations/141-rename-verification-table.js";
41
+ import { downRenameVerificationSessionIdColumn } from "../memory/migrations/142-rename-verification-session-id-column.js";
42
+ import { downRenameGuardianVerificationValues } from "../memory/migrations/143-rename-guardian-verification-values.js";
43
+ import { downRenameVoiceToPhone } from "../memory/migrations/144-rename-voice-to-phone.js";
44
+ import { migrateDropAccountsTableDown } from "../memory/migrations/145-drop-accounts-table.js";
45
+ import { migrateRemindersToSchedulesDown } from "../memory/migrations/147-migrate-reminders-to-schedules.js";
46
+ import { migrateDropRemindersTableDown } from "../memory/migrations/148-drop-reminders-table.js";
47
+ import { migrateOAuthAppsClientSecretPathDown } from "../memory/migrations/150-oauth-apps-client-secret-path.js";
48
+ import {
49
+ migrateGuardianTimestampsEpochMsDown,
50
+ migrateGuardianTimestampsRebuildDown,
51
+ } from "../memory/migrations/162-guardian-timestamps-epoch-ms.js";
52
+ import { migrateRenameGmailProviderKeyToGoogleDown } from "../memory/migrations/169-rename-gmail-provider-key-to-google.js";
53
+ import { migrateRenameThreadStartersTableDown } from "../memory/migrations/174-rename-thread-starters-table.js";
54
+ import { migrateDropCapabilityCardStateDown } from "../memory/migrations/176-drop-capability-card-state.js";
55
+ import { migrateBackfillInlineAttachmentsToDiskDown } from "../memory/migrations/180-backfill-inline-attachments-to-disk.js";
56
+ import { migrateRenameThreadStartersCheckpointsDown } from "../memory/migrations/181-rename-thread-starters-checkpoints.js";
57
+ import { migrateBackfillAudioAttachmentMimeTypesDown } from "../memory/migrations/191-backfill-audio-attachment-mime-types.js";
19
58
  import {
20
59
  migrateJobDeferrals,
21
60
  migrateMemoryEntityRelationDedup,
22
61
  migrateMemoryItemsFingerprintScopeUnique,
62
+ migrateMemoryItemsScopeSaltedFingerprints,
23
63
  MIGRATION_REGISTRY,
64
+ type MigrationRegistryEntry,
24
65
  type MigrationValidationResult,
66
+ rollbackMemoryMigration,
25
67
  validateMigrationState,
26
68
  } from "../memory/migrations/index.js";
27
69
  import * as schema from "../memory/schema.js";
@@ -877,3 +919,1975 @@ describe("schema-drift recovery: migration handles unexpected schema state", ()
877
919
  expect(countAfter2).toBe(3);
878
920
  });
879
921
  });
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // 3. rollbackMemoryMigration
925
+ // ---------------------------------------------------------------------------
926
+
927
+ describe("rollbackMemoryMigration", () => {
928
+ // Track test entries pushed onto MIGRATION_REGISTRY so we can restore after
929
+ // each test. This avoids polluting the real registry across test runs.
930
+ let registrySnapshot: MigrationRegistryEntry[];
931
+
932
+ function saveRegistry() {
933
+ registrySnapshot = [...MIGRATION_REGISTRY];
934
+ }
935
+
936
+ function restoreRegistry() {
937
+ MIGRATION_REGISTRY.length = 0;
938
+ MIGRATION_REGISTRY.push(...registrySnapshot);
939
+ }
940
+
941
+ afterEach(() => {
942
+ restoreRegistry();
943
+ });
944
+
945
+ test("rolls back checkpoint-tracked migrations in reverse version order", () => {
946
+ saveRegistry();
947
+
948
+ const db = createTestDb();
949
+ const raw = getRaw(db);
950
+ bootstrapCheckpointsTable(raw);
951
+
952
+ // Track execution order of down() calls.
953
+ const downCalls: string[] = [];
954
+
955
+ const now = Date.now();
956
+
957
+ // Use very high version numbers to avoid colliding with real registry entries.
958
+ const testEntries: MigrationRegistryEntry[] = [
959
+ {
960
+ key: "test_rollback_v1000",
961
+ version: 1000,
962
+ description: "test migration v1000",
963
+ down: () => {
964
+ downCalls.push("test_rollback_v1000");
965
+ },
966
+ },
967
+ {
968
+ key: "test_rollback_v1001",
969
+ version: 1001,
970
+ description: "test migration v1001",
971
+ down: () => {
972
+ downCalls.push("test_rollback_v1001");
973
+ },
974
+ },
975
+ {
976
+ key: "test_rollback_v1002",
977
+ version: 1002,
978
+ description: "test migration v1002",
979
+ down: () => {
980
+ downCalls.push("test_rollback_v1002");
981
+ },
982
+ },
983
+ ];
984
+
985
+ MIGRATION_REGISTRY.push(...testEntries);
986
+
987
+ // Simulate all three migrations as completed.
988
+ for (const entry of testEntries) {
989
+ raw.exec(
990
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('${entry.key}', '1', ${now})`,
991
+ );
992
+ }
993
+
994
+ // Roll back to version 1000 — should roll back v1002 and v1001 (version > 1000).
995
+ const rolledBack = rollbackMemoryMigration(db, 1000);
996
+
997
+ // Verify returned keys.
998
+ expect(rolledBack).toEqual(["test_rollback_v1002", "test_rollback_v1001"]);
999
+
1000
+ // Verify down() was called in reverse version order.
1001
+ expect(downCalls).toEqual(["test_rollback_v1002", "test_rollback_v1001"]);
1002
+
1003
+ // Checkpoints for rolled-back migrations should be deleted.
1004
+ const cp1001 = raw
1005
+ .query(
1006
+ `SELECT 1 FROM memory_checkpoints WHERE key = 'test_rollback_v1001'`,
1007
+ )
1008
+ .get();
1009
+ expect(cp1001).toBeNull();
1010
+
1011
+ const cp1002 = raw
1012
+ .query(
1013
+ `SELECT 1 FROM memory_checkpoints WHERE key = 'test_rollback_v1002'`,
1014
+ )
1015
+ .get();
1016
+ expect(cp1002).toBeNull();
1017
+
1018
+ // Checkpoint for the migration at target version should still exist.
1019
+ const cp1000 = raw
1020
+ .query(
1021
+ `SELECT value FROM memory_checkpoints WHERE key = 'test_rollback_v1000'`,
1022
+ )
1023
+ .get() as { value: string } | null;
1024
+ expect(cp1000).toBeTruthy();
1025
+ expect(cp1000!.value).toBe("1");
1026
+ });
1027
+
1028
+ test("handles transaction failure in down() — rolls back and preserves checkpoint", () => {
1029
+ saveRegistry();
1030
+
1031
+ const db = createTestDb();
1032
+ const raw = getRaw(db);
1033
+ bootstrapCheckpointsTable(raw);
1034
+
1035
+ const now = Date.now();
1036
+
1037
+ // Create a table that the down() function will try to modify.
1038
+ raw.exec(/*sql*/ `
1039
+ CREATE TABLE IF NOT EXISTS test_rollback_data (
1040
+ id TEXT PRIMARY KEY,
1041
+ value TEXT NOT NULL
1042
+ )
1043
+ `);
1044
+ raw.exec(
1045
+ `INSERT INTO test_rollback_data (id, value) VALUES ('row-1', 'original')`,
1046
+ );
1047
+
1048
+ // Register a migration whose down() modifies test_rollback_data,
1049
+ // but a trigger will force the modification to fail.
1050
+ MIGRATION_REGISTRY.push({
1051
+ key: "test_fail_down_v3000",
1052
+ version: 3000,
1053
+ description: "test migration with failing down()",
1054
+ down: (database) => {
1055
+ const sqlite = getSqliteFrom(database);
1056
+ // This UPDATE will trigger our failure trigger.
1057
+ sqlite.exec(
1058
+ `UPDATE test_rollback_data SET value = 'rolled-back' WHERE id = 'row-1'`,
1059
+ );
1060
+ },
1061
+ });
1062
+
1063
+ // Mark as completed.
1064
+ raw.exec(
1065
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_fail_down_v3000', '1', ${now})`,
1066
+ );
1067
+
1068
+ // Install a trigger to force the down() function to fail.
1069
+ raw.exec(/*sql*/ `
1070
+ CREATE TRIGGER fail_on_update_test_rollback AFTER UPDATE ON test_rollback_data
1071
+ BEGIN
1072
+ SELECT RAISE(ABORT, 'simulated down() failure');
1073
+ END
1074
+ `);
1075
+
1076
+ // Rollback should throw because down() fails.
1077
+ let threw = false;
1078
+ try {
1079
+ rollbackMemoryMigration(db, 2999);
1080
+ } catch {
1081
+ threw = true;
1082
+ }
1083
+ expect(threw).toBe(true);
1084
+
1085
+ // Remove the trigger for inspection.
1086
+ raw.exec(`DROP TRIGGER IF EXISTS fail_on_update_test_rollback`);
1087
+
1088
+ // The checkpoint should still exist — down() threw before execution reached
1089
+ // the DELETE FROM memory_checkpoints line. The 'rolling_back' marker was
1090
+ // written before down() was called and is preserved.
1091
+ const cp = raw
1092
+ .query(
1093
+ `SELECT value FROM memory_checkpoints WHERE key = 'test_fail_down_v3000'`,
1094
+ )
1095
+ .get() as { value: string } | null;
1096
+ expect(cp).toBeTruthy();
1097
+ expect(cp!.value).toBe("rolling_back");
1098
+
1099
+ // The data should be unchanged — the RAISE(ABORT) trigger aborted the statement.
1100
+ const row = raw
1101
+ .query(`SELECT value FROM test_rollback_data WHERE id = 'row-1'`)
1102
+ .get() as { value: string } | null;
1103
+ expect(row).toBeTruthy();
1104
+ expect(row!.value).toBe("original");
1105
+ });
1106
+
1107
+ test("down() with its own BEGIN/COMMIT succeeds without nested-transaction errors", () => {
1108
+ saveRegistry();
1109
+
1110
+ const db = createTestDb();
1111
+ const raw = getRaw(db);
1112
+ bootstrapCheckpointsTable(raw);
1113
+
1114
+ const now = Date.now();
1115
+
1116
+ // Create a table for the down() function to operate on.
1117
+ raw.exec(/*sql*/ `
1118
+ CREATE TABLE IF NOT EXISTS test_self_txn_data (
1119
+ id TEXT PRIMARY KEY,
1120
+ value TEXT NOT NULL
1121
+ )
1122
+ `);
1123
+ raw.exec(
1124
+ `INSERT INTO test_self_txn_data (id, value) VALUES ('row-1', 'migrated')`,
1125
+ );
1126
+
1127
+ // Register a migration whose down() manages its own transaction —
1128
+ // this previously caused nested-transaction errors when rollbackMemoryMigration
1129
+ // wrapped every down() call in BEGIN/COMMIT.
1130
+ MIGRATION_REGISTRY.push({
1131
+ key: "test_self_txn_down_v3500",
1132
+ version: 3500,
1133
+ description: "test migration with self-transactional down()",
1134
+ down: (database) => {
1135
+ const sqlite = getSqliteFrom(database);
1136
+ sqlite.exec("BEGIN");
1137
+ sqlite.exec(
1138
+ `UPDATE test_self_txn_data SET value = 'original' WHERE id = 'row-1'`,
1139
+ );
1140
+ sqlite.exec("COMMIT");
1141
+ },
1142
+ });
1143
+
1144
+ // Mark as completed.
1145
+ raw.exec(
1146
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_self_txn_down_v3500', '1', ${now})`,
1147
+ );
1148
+
1149
+ // This should succeed — no nested transaction error.
1150
+ const rolledBack = rollbackMemoryMigration(db, 3499);
1151
+
1152
+ expect(rolledBack).toEqual(["test_self_txn_down_v3500"]);
1153
+
1154
+ // Verify the down() function's changes were applied.
1155
+ const row = raw
1156
+ .query(`SELECT value FROM test_self_txn_data WHERE id = 'row-1'`)
1157
+ .get() as { value: string } | null;
1158
+ expect(row).toBeTruthy();
1159
+ expect(row!.value).toBe("original");
1160
+
1161
+ // Checkpoint should be deleted.
1162
+ const cp = raw
1163
+ .query(
1164
+ `SELECT 1 FROM memory_checkpoints WHERE key = 'test_self_txn_down_v3500'`,
1165
+ )
1166
+ .get();
1167
+ expect(cp).toBeNull();
1168
+ });
1169
+
1170
+ test("no-op when already at target version", () => {
1171
+ saveRegistry();
1172
+
1173
+ const db = createTestDb();
1174
+ const raw = getRaw(db);
1175
+ bootstrapCheckpointsTable(raw);
1176
+
1177
+ const now = Date.now();
1178
+
1179
+ // Register entries with down functions — they should NOT be called.
1180
+ const downCalls: string[] = [];
1181
+
1182
+ MIGRATION_REGISTRY.push(
1183
+ {
1184
+ key: "test_noop_v4000",
1185
+ version: 4000,
1186
+ description: "test noop v4000",
1187
+ down: () => {
1188
+ downCalls.push("test_noop_v4000");
1189
+ },
1190
+ },
1191
+ {
1192
+ key: "test_noop_v4001",
1193
+ version: 4001,
1194
+ description: "test noop v4001",
1195
+ down: () => {
1196
+ downCalls.push("test_noop_v4001");
1197
+ },
1198
+ },
1199
+ );
1200
+
1201
+ // Mark both as completed.
1202
+ raw.exec(
1203
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_noop_v4000', '1', ${now})`,
1204
+ );
1205
+ raw.exec(
1206
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_noop_v4001', '1', ${now})`,
1207
+ );
1208
+
1209
+ // Roll back to version >= latest applied migration — should be a no-op.
1210
+ const rolledBack = rollbackMemoryMigration(db, 4001);
1211
+
1212
+ expect(rolledBack).toEqual([]);
1213
+ expect(downCalls).toEqual([]);
1214
+
1215
+ // Both checkpoints should remain.
1216
+ const cp4000 = raw
1217
+ .query(
1218
+ `SELECT value FROM memory_checkpoints WHERE key = 'test_noop_v4000'`,
1219
+ )
1220
+ .get() as { value: string } | null;
1221
+ const cp4001 = raw
1222
+ .query(
1223
+ `SELECT value FROM memory_checkpoints WHERE key = 'test_noop_v4001'`,
1224
+ )
1225
+ .get() as { value: string } | null;
1226
+ expect(cp4000!.value).toBe("1");
1227
+ expect(cp4001!.value).toBe("1");
1228
+
1229
+ // Also verify with a target version greater than the latest.
1230
+ const rolledBack2 = rollbackMemoryMigration(db, 9999);
1231
+ expect(rolledBack2).toEqual([]);
1232
+ expect(downCalls).toEqual([]);
1233
+ });
1234
+
1235
+ test("respects dependency ordering on rollback (children rolled back before parents)", () => {
1236
+ saveRegistry();
1237
+
1238
+ const db = createTestDb();
1239
+ const raw = getRaw(db);
1240
+ bootstrapCheckpointsTable(raw);
1241
+
1242
+ const now = Date.now();
1243
+ const downCalls: string[] = [];
1244
+
1245
+ // Parent migration at version 5000 — has a down().
1246
+ // Child migration at version 5001 — depends on parent, has a down().
1247
+ // Since the child has a higher version number, rolling back in reverse
1248
+ // version order means the child (v5001) is rolled back BEFORE the parent
1249
+ // (v5000), which is the correct dependency-safe ordering.
1250
+ MIGRATION_REGISTRY.push(
1251
+ {
1252
+ key: "test_parent_v5000",
1253
+ version: 5000,
1254
+ description: "test parent migration",
1255
+ down: () => {
1256
+ downCalls.push("test_parent_v5000");
1257
+ },
1258
+ },
1259
+ {
1260
+ key: "test_child_v5001",
1261
+ version: 5001,
1262
+ dependsOn: ["test_parent_v5000"],
1263
+ description: "test child migration depending on parent",
1264
+ down: () => {
1265
+ downCalls.push("test_child_v5001");
1266
+ },
1267
+ },
1268
+ );
1269
+
1270
+ // Both are completed.
1271
+ raw.exec(
1272
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_parent_v5000', '1', ${now})`,
1273
+ );
1274
+ raw.exec(
1275
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('test_child_v5001', '1', ${now})`,
1276
+ );
1277
+
1278
+ // Roll back to version 4999 — both should be rolled back, child first.
1279
+ const rolledBack = rollbackMemoryMigration(db, 4999);
1280
+
1281
+ expect(rolledBack).toEqual(["test_child_v5001", "test_parent_v5000"]);
1282
+
1283
+ // Verify down() execution order: child before parent.
1284
+ expect(downCalls).toEqual(["test_child_v5001", "test_parent_v5000"]);
1285
+
1286
+ // Both checkpoints should be deleted.
1287
+ const cpParent = raw
1288
+ .query(`SELECT 1 FROM memory_checkpoints WHERE key = 'test_parent_v5000'`)
1289
+ .get();
1290
+ const cpChild = raw
1291
+ .query(`SELECT 1 FROM memory_checkpoints WHERE key = 'test_child_v5001'`)
1292
+ .get();
1293
+ expect(cpParent).toBeNull();
1294
+ expect(cpChild).toBeNull();
1295
+ });
1296
+ });
1297
+
1298
+ // ---------------------------------------------------------------------------
1299
+ // 4. Memory migration down() functions
1300
+ // ---------------------------------------------------------------------------
1301
+
1302
+ describe("memory migration down() functions", () => {
1303
+ // ── v1: downJobDeferrals ─────────────────────────────────────────────
1304
+
1305
+ describe("v1: downJobDeferrals", () => {
1306
+ test("round-trip: forward + down restores original state", () => {
1307
+ const db = createTestDb();
1308
+ const raw = getRaw(db);
1309
+ bootstrapCheckpointsTable(raw);
1310
+ bootstrapMemoryJobsTable(raw);
1311
+
1312
+ const now = Date.now();
1313
+ raw.exec(`
1314
+ INSERT INTO memory_jobs (id, type, payload, status, attempts, deferrals, run_after, last_error, created_at, updated_at)
1315
+ VALUES ('job-rt', 'embed_segment', '{}', 'pending', 3, 0, ${now}, NULL, ${now}, ${now})
1316
+ `);
1317
+
1318
+ // Snapshot pre-migration state.
1319
+ const before = raw
1320
+ .query(
1321
+ `SELECT attempts, deferrals FROM memory_jobs WHERE id = 'job-rt'`,
1322
+ )
1323
+ .get() as { attempts: number; deferrals: number };
1324
+ expect(before.attempts).toBe(3);
1325
+ expect(before.deferrals).toBe(0);
1326
+
1327
+ // Forward migration: moves attempts -> deferrals.
1328
+ migrateJobDeferrals(db);
1329
+
1330
+ const afterForward = raw
1331
+ .query(
1332
+ `SELECT attempts, deferrals FROM memory_jobs WHERE id = 'job-rt'`,
1333
+ )
1334
+ .get() as { attempts: number; deferrals: number };
1335
+ expect(afterForward.attempts).toBe(0);
1336
+ expect(afterForward.deferrals).toBe(3);
1337
+
1338
+ // Down: moves deferrals -> attempts.
1339
+ downJobDeferrals(db);
1340
+
1341
+ const afterDown = raw
1342
+ .query(
1343
+ `SELECT attempts, deferrals FROM memory_jobs WHERE id = 'job-rt'`,
1344
+ )
1345
+ .get() as { attempts: number; deferrals: number };
1346
+ expect(afterDown.attempts).toBe(3);
1347
+ expect(afterDown.deferrals).toBe(0);
1348
+ });
1349
+
1350
+ test("idempotency: calling down twice does not throw", () => {
1351
+ const db = createTestDb();
1352
+ const raw = getRaw(db);
1353
+ bootstrapCheckpointsTable(raw);
1354
+ bootstrapMemoryJobsTable(raw);
1355
+
1356
+ const now = Date.now();
1357
+ raw.exec(`
1358
+ INSERT INTO memory_jobs (id, type, payload, status, attempts, deferrals, run_after, last_error, created_at, updated_at)
1359
+ VALUES ('job-idem2', 'embed_item', '{}', 'pending', 0, 5, ${now}, NULL, ${now}, ${now})
1360
+ `);
1361
+
1362
+ downJobDeferrals(db);
1363
+ const after1 = raw
1364
+ .query(
1365
+ `SELECT attempts, deferrals FROM memory_jobs WHERE id = 'job-idem2'`,
1366
+ )
1367
+ .get() as { attempts: number; deferrals: number };
1368
+
1369
+ // Second call — should be a no-op (deferrals already 0).
1370
+ downJobDeferrals(db);
1371
+ const after2 = raw
1372
+ .query(
1373
+ `SELECT attempts, deferrals FROM memory_jobs WHERE id = 'job-idem2'`,
1374
+ )
1375
+ .get() as { attempts: number; deferrals: number };
1376
+
1377
+ expect(after1.attempts).toBe(5);
1378
+ expect(after1.deferrals).toBe(0);
1379
+ expect(after2.attempts).toBe(after1.attempts);
1380
+ expect(after2.deferrals).toBe(after1.deferrals);
1381
+ });
1382
+ });
1383
+
1384
+ // ── v2: downMemoryEntityRelationDedup (no-op) ────────────────────────
1385
+
1386
+ describe("v2: downMemoryEntityRelationDedup (no-op)", () => {
1387
+ test("does not throw and does not modify data", () => {
1388
+ const db = createTestDb();
1389
+ const raw = getRaw(db);
1390
+ bootstrapEntityRelationsTable(raw);
1391
+
1392
+ const now = Date.now();
1393
+ raw.exec(
1394
+ `INSERT INTO memory_entity_relations VALUES ('r1', 'e1', 'e2', 'knows', 'ev', ${now}, ${now})`,
1395
+ );
1396
+
1397
+ const countBefore = (
1398
+ raw
1399
+ .query(`SELECT COUNT(*) AS c FROM memory_entity_relations`)
1400
+ .get() as { c: number }
1401
+ ).c;
1402
+
1403
+ downMemoryEntityRelationDedup(db);
1404
+
1405
+ const countAfter = (
1406
+ raw
1407
+ .query(`SELECT COUNT(*) AS c FROM memory_entity_relations`)
1408
+ .get() as { c: number }
1409
+ ).c;
1410
+ expect(countAfter).toBe(countBefore);
1411
+ });
1412
+
1413
+ test("idempotency: calling twice does not throw", () => {
1414
+ const db = createTestDb();
1415
+ downMemoryEntityRelationDedup(db);
1416
+ downMemoryEntityRelationDedup(db);
1417
+ });
1418
+ });
1419
+
1420
+ // ── v3: downMemoryItemsFingerprintScopeUnique ────────────────────────
1421
+
1422
+ describe("v3: downMemoryItemsFingerprintScopeUnique", () => {
1423
+ test("round-trip: forward + down restores column-level UNIQUE", () => {
1424
+ const db = createTestDb();
1425
+ const raw = getRaw(db);
1426
+ bootstrapCheckpointsTable(raw);
1427
+ bootstrapOldMemoryItemsTable(raw);
1428
+
1429
+ const now = Date.now();
1430
+ raw.exec(`
1431
+ INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint,
1432
+ first_seen_at, last_seen_at, scope_id)
1433
+ VALUES ('item-rt', 'fact', 'User', 'likes coffee', 'active', 0.9, 'fp-rt1', ${now}, ${now}, 'default')
1434
+ `);
1435
+
1436
+ // Old schema has UNIQUE on fingerprint.
1437
+ const ddlBefore =
1438
+ (
1439
+ raw
1440
+ .query(
1441
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_items'`,
1442
+ )
1443
+ .get() as { sql: string }
1444
+ )?.sql ?? "";
1445
+ expect(ddlBefore).toMatch(/fingerprint\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i);
1446
+
1447
+ // Forward migration: remove column-level UNIQUE.
1448
+ migrateMemoryItemsFingerprintScopeUnique(db);
1449
+
1450
+ const ddlAfterForward =
1451
+ (
1452
+ raw
1453
+ .query(
1454
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_items'`,
1455
+ )
1456
+ .get() as { sql: string }
1457
+ )?.sql ?? "";
1458
+ expect(ddlAfterForward).not.toMatch(
1459
+ /fingerprint\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i,
1460
+ );
1461
+
1462
+ // Down: restore column-level UNIQUE.
1463
+ downMemoryItemsFingerprintScopeUnique(db);
1464
+
1465
+ const ddlAfterDown =
1466
+ (
1467
+ raw
1468
+ .query(
1469
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_items'`,
1470
+ )
1471
+ .get() as { sql: string }
1472
+ )?.sql ?? "";
1473
+ expect(ddlAfterDown).toMatch(/fingerprint\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i);
1474
+
1475
+ // Data preserved.
1476
+ const item = raw
1477
+ .query(`SELECT id FROM memory_items WHERE id = 'item-rt'`)
1478
+ .get();
1479
+ expect(item).toBeTruthy();
1480
+ });
1481
+
1482
+ test("idempotency: calling down twice does not throw", () => {
1483
+ const db = createTestDb();
1484
+ const raw = getRaw(db);
1485
+ bootstrapCheckpointsTable(raw);
1486
+ bootstrapOldMemoryItemsTable(raw);
1487
+
1488
+ migrateMemoryItemsFingerprintScopeUnique(db);
1489
+ downMemoryItemsFingerprintScopeUnique(db);
1490
+ // Second call — column-level UNIQUE already restored.
1491
+ downMemoryItemsFingerprintScopeUnique(db);
1492
+
1493
+ const ddl =
1494
+ (
1495
+ raw
1496
+ .query(
1497
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_items'`,
1498
+ )
1499
+ .get() as { sql: string }
1500
+ )?.sql ?? "";
1501
+ expect(ddl).toMatch(/fingerprint\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i);
1502
+ });
1503
+ });
1504
+
1505
+ // ── v4: downMemoryItemsScopeSaltedFingerprints ───────────────────────
1506
+
1507
+ describe("v4: downMemoryItemsScopeSaltedFingerprints", () => {
1508
+ test("round-trip: forward + down restores unsalted fingerprints", () => {
1509
+ const db = createTestDb();
1510
+ const raw = getRaw(db);
1511
+ bootstrapCheckpointsTable(raw);
1512
+
1513
+ // Use modern schema (no column-level UNIQUE).
1514
+ raw.exec(/*sql*/ `
1515
+ CREATE TABLE IF NOT EXISTS memory_items (
1516
+ id TEXT PRIMARY KEY,
1517
+ kind TEXT NOT NULL,
1518
+ subject TEXT NOT NULL,
1519
+ statement TEXT NOT NULL,
1520
+ status TEXT NOT NULL,
1521
+ confidence REAL NOT NULL,
1522
+ fingerprint TEXT NOT NULL,
1523
+ first_seen_at INTEGER NOT NULL,
1524
+ last_seen_at INTEGER NOT NULL,
1525
+ last_used_at INTEGER,
1526
+ scope_id TEXT NOT NULL DEFAULT 'default'
1527
+ )
1528
+ `);
1529
+
1530
+ // Compute the old (unsalted) fingerprint.
1531
+ const kind = "fact";
1532
+ const subject = "User";
1533
+ const statement = "likes coffee";
1534
+ const oldNormalized = `${kind}|${subject.toLowerCase()}|${statement.toLowerCase()}`;
1535
+ const oldFingerprint = createHash("sha256")
1536
+ .update(oldNormalized)
1537
+ .digest("hex");
1538
+
1539
+ const now = Date.now();
1540
+ raw.exec(`
1541
+ INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint,
1542
+ first_seen_at, last_seen_at, scope_id)
1543
+ VALUES ('item-salt', '${kind}', '${subject}', '${statement}', 'active', 0.9, '${oldFingerprint}', ${now}, ${now}, 'default')
1544
+ `);
1545
+
1546
+ // Write fingerprint_scope_unique checkpoint so forward migration runs.
1547
+ raw.exec(
1548
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('migration_memory_items_fingerprint_scope_unique_v1', '1', ${now})`,
1549
+ );
1550
+
1551
+ // Forward migration: recompute with scope_id prefix.
1552
+ migrateMemoryItemsScopeSaltedFingerprints(db);
1553
+
1554
+ const afterForward = raw
1555
+ .query(`SELECT fingerprint FROM memory_items WHERE id = 'item-salt'`)
1556
+ .get() as { fingerprint: string };
1557
+ expect(afterForward.fingerprint).not.toBe(oldFingerprint);
1558
+
1559
+ // Down: recompute WITHOUT scope_id prefix (old format).
1560
+ downMemoryItemsScopeSaltedFingerprints(db);
1561
+
1562
+ const afterDown = raw
1563
+ .query(`SELECT fingerprint FROM memory_items WHERE id = 'item-salt'`)
1564
+ .get() as { fingerprint: string };
1565
+ expect(afterDown.fingerprint).toBe(oldFingerprint);
1566
+ });
1567
+
1568
+ test("idempotency: calling down twice does not throw", () => {
1569
+ const db = createTestDb();
1570
+ const raw = getRaw(db);
1571
+
1572
+ raw.exec(/*sql*/ `
1573
+ CREATE TABLE IF NOT EXISTS memory_items (
1574
+ id TEXT PRIMARY KEY,
1575
+ kind TEXT NOT NULL,
1576
+ subject TEXT NOT NULL,
1577
+ statement TEXT NOT NULL,
1578
+ status TEXT NOT NULL,
1579
+ confidence REAL NOT NULL,
1580
+ fingerprint TEXT NOT NULL,
1581
+ first_seen_at INTEGER NOT NULL,
1582
+ last_seen_at INTEGER NOT NULL,
1583
+ scope_id TEXT NOT NULL DEFAULT 'default'
1584
+ )
1585
+ `);
1586
+
1587
+ const now = Date.now();
1588
+ raw.exec(`
1589
+ INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint,
1590
+ first_seen_at, last_seen_at, scope_id)
1591
+ VALUES ('item-idem', 'fact', 'User', 'likes tea', 'active', 0.8, 'some-fp', ${now}, ${now}, 'default')
1592
+ `);
1593
+
1594
+ downMemoryItemsScopeSaltedFingerprints(db);
1595
+ const fp1 = (
1596
+ raw
1597
+ .query(`SELECT fingerprint FROM memory_items WHERE id = 'item-idem'`)
1598
+ .get() as { fingerprint: string }
1599
+ ).fingerprint;
1600
+
1601
+ downMemoryItemsScopeSaltedFingerprints(db);
1602
+ const fp2 = (
1603
+ raw
1604
+ .query(`SELECT fingerprint FROM memory_items WHERE id = 'item-idem'`)
1605
+ .get() as { fingerprint: string }
1606
+ ).fingerprint;
1607
+
1608
+ expect(fp1).toBe(fp2);
1609
+ });
1610
+ });
1611
+
1612
+ // ── No-op down functions (v5, v7/assistant-id-to-self, v8, v10, v14, v17, v18/contacts-notes, v20, v26, v33, v34, v36) ──
1613
+
1614
+ describe("no-op down() functions", () => {
1615
+ const noOpFunctions = [
1616
+ { name: "v5: downAssistantIdToSelf", fn: downAssistantIdToSelf },
1617
+ {
1618
+ name: "v8: downBackfillInboxThreadState",
1619
+ fn: downBackfillInboxThreadState,
1620
+ },
1621
+ {
1622
+ name: "v10: downNotificationTablesSchema",
1623
+ fn: downNotificationTablesSchema,
1624
+ },
1625
+ {
1626
+ name: "v14: downNormalizePhoneIdentities",
1627
+ fn: downNormalizePhoneIdentities,
1628
+ },
1629
+ { name: "v17: downContactsNotesColumn", fn: downContactsNotesColumn },
1630
+ {
1631
+ name: "v20: downBackfillUsageCacheAccounting",
1632
+ fn: downBackfillUsageCacheAccounting,
1633
+ },
1634
+ {
1635
+ name: "v26: migrateRemindersToSchedulesDown",
1636
+ fn: migrateRemindersToSchedulesDown,
1637
+ },
1638
+ {
1639
+ name: "v33: migrateDropCapabilityCardStateDown",
1640
+ fn: migrateDropCapabilityCardStateDown,
1641
+ },
1642
+ {
1643
+ name: "v34: migrateBackfillInlineAttachmentsToDiskDown",
1644
+ fn: migrateBackfillInlineAttachmentsToDiskDown,
1645
+ },
1646
+ {
1647
+ name: "v36: migrateBackfillAudioAttachmentMimeTypesDown",
1648
+ fn: migrateBackfillAudioAttachmentMimeTypesDown,
1649
+ },
1650
+ ];
1651
+
1652
+ for (const { name, fn } of noOpFunctions) {
1653
+ test(`${name}: does not throw`, () => {
1654
+ const db = createTestDb();
1655
+ expect(() => fn(db)).not.toThrow();
1656
+ });
1657
+
1658
+ test(`${name}: idempotency — calling twice does not throw`, () => {
1659
+ const db = createTestDb();
1660
+ fn(db);
1661
+ fn(db);
1662
+ });
1663
+ }
1664
+ });
1665
+
1666
+ // ── v6: downRemoveAssistantIdColumns (re-add via ALTER TABLE) ────────
1667
+
1668
+ describe("v6: downRemoveAssistantIdColumns", () => {
1669
+ test("adds assistant_id column back to tables that lack it", () => {
1670
+ const db = createTestDb();
1671
+ const raw = getRaw(db);
1672
+
1673
+ // Create tables WITHOUT assistant_id (post-forward-migration state).
1674
+ raw.exec(/*sql*/ `
1675
+ CREATE TABLE conversations (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL);
1676
+ CREATE TABLE conversation_keys (id TEXT PRIMARY KEY, conversation_key TEXT NOT NULL UNIQUE, conversation_id TEXT NOT NULL, created_at INTEGER NOT NULL);
1677
+ CREATE TABLE attachments (id TEXT PRIMARY KEY, original_filename TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, kind TEXT NOT NULL, data_base64 TEXT NOT NULL, content_hash TEXT, thumbnail_base64 TEXT, created_at INTEGER NOT NULL);
1678
+ CREATE TABLE channel_inbound_events (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL, external_chat_id TEXT NOT NULL, external_message_id TEXT NOT NULL, conversation_id TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
1679
+ CREATE TABLE messages (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL);
1680
+ CREATE TABLE message_runs (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
1681
+ `);
1682
+
1683
+ downRemoveAssistantIdColumns(db);
1684
+
1685
+ // Verify assistant_id column was added to the 4 affected tables.
1686
+ for (const table of [
1687
+ "conversation_keys",
1688
+ "attachments",
1689
+ "channel_inbound_events",
1690
+ "message_runs",
1691
+ ]) {
1692
+ const col = raw
1693
+ .query(
1694
+ `SELECT 1 FROM pragma_table_info('${table}') WHERE name = 'assistant_id'`,
1695
+ )
1696
+ .get();
1697
+ expect(col).toBeTruthy();
1698
+ }
1699
+ });
1700
+
1701
+ test("idempotency: calling down twice does not throw", () => {
1702
+ const db = createTestDb();
1703
+ const raw = getRaw(db);
1704
+
1705
+ raw.exec(/*sql*/ `
1706
+ CREATE TABLE conversations (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL);
1707
+ CREATE TABLE conversation_keys (id TEXT PRIMARY KEY, conversation_key TEXT NOT NULL, conversation_id TEXT NOT NULL, created_at INTEGER NOT NULL);
1708
+ CREATE TABLE attachments (id TEXT PRIMARY KEY, original_filename TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, kind TEXT NOT NULL, data_base64 TEXT NOT NULL, content_hash TEXT, created_at INTEGER NOT NULL);
1709
+ CREATE TABLE channel_inbound_events (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL, external_chat_id TEXT NOT NULL, external_message_id TEXT NOT NULL, conversation_id TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
1710
+ CREATE TABLE message_runs (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
1711
+ `);
1712
+
1713
+ downRemoveAssistantIdColumns(db);
1714
+ downRemoveAssistantIdColumns(db);
1715
+ });
1716
+ });
1717
+
1718
+ // ── v7: downLlmUsageEventsDropAssistantId (re-add via ALTER TABLE) ──
1719
+
1720
+ describe("v7: downLlmUsageEventsDropAssistantId", () => {
1721
+ test("adds assistant_id column back to llm_usage_events", () => {
1722
+ const db = createTestDb();
1723
+ const raw = getRaw(db);
1724
+
1725
+ raw.exec(/*sql*/ `
1726
+ CREATE TABLE llm_usage_events (
1727
+ id TEXT PRIMARY KEY,
1728
+ created_at INTEGER NOT NULL,
1729
+ actor TEXT NOT NULL,
1730
+ provider TEXT NOT NULL,
1731
+ model TEXT NOT NULL,
1732
+ input_tokens INTEGER NOT NULL,
1733
+ output_tokens INTEGER NOT NULL,
1734
+ pricing_status TEXT NOT NULL
1735
+ )
1736
+ `);
1737
+
1738
+ downLlmUsageEventsDropAssistantId(db);
1739
+
1740
+ const col = raw
1741
+ .query(
1742
+ `SELECT 1 FROM pragma_table_info('llm_usage_events') WHERE name = 'assistant_id'`,
1743
+ )
1744
+ .get();
1745
+ expect(col).toBeTruthy();
1746
+ });
1747
+
1748
+ test("idempotency: calling down twice does not throw", () => {
1749
+ const db = createTestDb();
1750
+ const raw = getRaw(db);
1751
+
1752
+ raw.exec(/*sql*/ `
1753
+ CREATE TABLE llm_usage_events (
1754
+ id TEXT PRIMARY KEY,
1755
+ created_at INTEGER NOT NULL,
1756
+ actor TEXT NOT NULL,
1757
+ provider TEXT NOT NULL,
1758
+ model TEXT NOT NULL,
1759
+ input_tokens INTEGER NOT NULL,
1760
+ output_tokens INTEGER NOT NULL,
1761
+ pricing_status TEXT NOT NULL
1762
+ )
1763
+ `);
1764
+
1765
+ downLlmUsageEventsDropAssistantId(db);
1766
+ downLlmUsageEventsDropAssistantId(db);
1767
+ });
1768
+ });
1769
+
1770
+ // ── v9: downDropActiveSearchIndex ────────────────────────────────────
1771
+
1772
+ describe("v9: downDropActiveSearchIndex", () => {
1773
+ test("recreates the old index", () => {
1774
+ const db = createTestDb();
1775
+ const raw = getRaw(db);
1776
+ bootstrapOldMemoryItemsTable(raw);
1777
+
1778
+ downDropActiveSearchIndex(db);
1779
+
1780
+ const idx = raw
1781
+ .query(
1782
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_memory_items_active_search'`,
1783
+ )
1784
+ .get();
1785
+ expect(idx).toBeTruthy();
1786
+ });
1787
+
1788
+ test("idempotency: calling down twice does not throw", () => {
1789
+ const db = createTestDb();
1790
+ const raw = getRaw(db);
1791
+ bootstrapOldMemoryItemsTable(raw);
1792
+
1793
+ downDropActiveSearchIndex(db);
1794
+ downDropActiveSearchIndex(db);
1795
+ });
1796
+ });
1797
+
1798
+ // ── v11: downRenameChannelToVellum (value rename) ───────────────────
1799
+
1800
+ describe("v11: downRenameChannelToVellum", () => {
1801
+ test("renames 'vellum' values back to 'macos'", () => {
1802
+ const db = createTestDb();
1803
+ const raw = getRaw(db);
1804
+
1805
+ raw.exec(/*sql*/ `
1806
+ CREATE TABLE guardian_action_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL);
1807
+ INSERT INTO guardian_action_deliveries VALUES ('d1', 'vellum');
1808
+ INSERT INTO guardian_action_deliveries VALUES ('d2', 'sms');
1809
+ `);
1810
+
1811
+ downRenameChannelToVellum(db);
1812
+
1813
+ const row = raw
1814
+ .query(
1815
+ `SELECT destination_channel FROM guardian_action_deliveries WHERE id = 'd1'`,
1816
+ )
1817
+ .get() as { destination_channel: string };
1818
+ expect(row.destination_channel).toBe("macos");
1819
+
1820
+ // Non-vellum values are unchanged.
1821
+ const row2 = raw
1822
+ .query(
1823
+ `SELECT destination_channel FROM guardian_action_deliveries WHERE id = 'd2'`,
1824
+ )
1825
+ .get() as { destination_channel: string };
1826
+ expect(row2.destination_channel).toBe("sms");
1827
+ });
1828
+
1829
+ test("idempotency: calling down twice does not throw", () => {
1830
+ const db = createTestDb();
1831
+ const raw = getRaw(db);
1832
+ raw.exec(
1833
+ `CREATE TABLE guardian_action_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL)`,
1834
+ );
1835
+ raw.exec(
1836
+ `INSERT INTO guardian_action_deliveries VALUES ('d1', 'vellum')`,
1837
+ );
1838
+
1839
+ downRenameChannelToVellum(db);
1840
+ downRenameChannelToVellum(db);
1841
+
1842
+ const row = raw
1843
+ .query(
1844
+ `SELECT destination_channel FROM guardian_action_deliveries WHERE id = 'd1'`,
1845
+ )
1846
+ .get() as { destination_channel: string };
1847
+ expect(row.destination_channel).toBe("macos");
1848
+ });
1849
+ });
1850
+
1851
+ // ── v19: downDropAssistantIdColumns (16-table column re-add) ────────
1852
+
1853
+ describe("v19: downDropAssistantIdColumns", () => {
1854
+ test("adds assistant_id column to tables that lack it", () => {
1855
+ const db = createTestDb();
1856
+ const raw = getRaw(db);
1857
+
1858
+ // Create a subset of the 16 tables without assistant_id.
1859
+ raw.exec(
1860
+ `CREATE TABLE contacts (id TEXT PRIMARY KEY, name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
1861
+ );
1862
+ raw.exec(
1863
+ `CREATE TABLE notification_events (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)`,
1864
+ );
1865
+
1866
+ downDropAssistantIdColumns(db);
1867
+
1868
+ for (const table of ["contacts", "notification_events"]) {
1869
+ const col = raw
1870
+ .query(
1871
+ `SELECT 1 FROM pragma_table_info('${table}') WHERE name = 'assistant_id'`,
1872
+ )
1873
+ .get();
1874
+ expect(col).toBeTruthy();
1875
+ }
1876
+ });
1877
+
1878
+ test("idempotency: calling down twice does not throw", () => {
1879
+ const db = createTestDb();
1880
+ const raw = getRaw(db);
1881
+ raw.exec(
1882
+ `CREATE TABLE contacts (id TEXT PRIMARY KEY, name TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
1883
+ );
1884
+
1885
+ downDropAssistantIdColumns(db);
1886
+ downDropAssistantIdColumns(db);
1887
+ });
1888
+ });
1889
+
1890
+ // ── v21: downRenameVerificationTable (table rename) ─────────────────
1891
+
1892
+ describe("v21: downRenameVerificationTable", () => {
1893
+ test("renames channel_verification_sessions back to channel_guardian_verification_challenges", () => {
1894
+ const db = createTestDb();
1895
+ const raw = getRaw(db);
1896
+
1897
+ // Setup: new table name (post-forward-migration).
1898
+ raw.exec(/*sql*/ `
1899
+ CREATE TABLE channel_verification_sessions (
1900
+ id TEXT PRIMARY KEY,
1901
+ channel TEXT NOT NULL,
1902
+ challenge_hash TEXT,
1903
+ status TEXT NOT NULL,
1904
+ expected_external_user_id TEXT,
1905
+ expected_chat_id TEXT,
1906
+ destination_address TEXT,
1907
+ bootstrap_token_hash TEXT,
1908
+ created_at INTEGER NOT NULL
1909
+ )
1910
+ `);
1911
+ raw.exec(
1912
+ /*sql*/ `CREATE INDEX idx_verification_sessions_lookup ON channel_verification_sessions(channel, challenge_hash, status)`,
1913
+ );
1914
+
1915
+ downRenameVerificationTable(db);
1916
+
1917
+ // Old table name should exist.
1918
+ const oldTable = raw
1919
+ .query(
1920
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_verification_challenges'`,
1921
+ )
1922
+ .get();
1923
+ expect(oldTable).toBeTruthy();
1924
+
1925
+ // New table name should no longer exist.
1926
+ const newTable = raw
1927
+ .query(
1928
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_verification_sessions'`,
1929
+ )
1930
+ .get();
1931
+ expect(newTable).toBeNull();
1932
+
1933
+ // Old-style indexes should exist.
1934
+ const oldIdx = raw
1935
+ .query(
1936
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_channel_guardian_challenges_lookup'`,
1937
+ )
1938
+ .get();
1939
+ expect(oldIdx).toBeTruthy();
1940
+ });
1941
+
1942
+ test("idempotency: calling down twice does not throw", () => {
1943
+ const db = createTestDb();
1944
+ const raw = getRaw(db);
1945
+ raw.exec(
1946
+ `CREATE TABLE channel_verification_sessions (id TEXT PRIMARY KEY, channel TEXT NOT NULL, challenge_hash TEXT, status TEXT NOT NULL, expected_external_user_id TEXT, expected_chat_id TEXT, destination_address TEXT, bootstrap_token_hash TEXT, created_at INTEGER NOT NULL)`,
1947
+ );
1948
+
1949
+ downRenameVerificationTable(db);
1950
+ downRenameVerificationTable(db);
1951
+ });
1952
+ });
1953
+
1954
+ // ── v22: downRenameVerificationSessionIdColumn ──────────────────────
1955
+
1956
+ describe("v22: downRenameVerificationSessionIdColumn", () => {
1957
+ test("renames verification_session_id back to guardian_verification_session_id", () => {
1958
+ const db = createTestDb();
1959
+ const raw = getRaw(db);
1960
+
1961
+ raw.exec(/*sql*/ `
1962
+ CREATE TABLE call_sessions (
1963
+ id TEXT PRIMARY KEY,
1964
+ verification_session_id TEXT,
1965
+ created_at INTEGER NOT NULL
1966
+ )
1967
+ `);
1968
+
1969
+ downRenameVerificationSessionIdColumn(db);
1970
+
1971
+ const columns = raw
1972
+ .query(`PRAGMA table_info(call_sessions)`)
1973
+ .all() as Array<{ name: string }>;
1974
+ const hasOld = columns.some(
1975
+ (c) => c.name === "guardian_verification_session_id",
1976
+ );
1977
+ const hasNew = columns.some((c) => c.name === "verification_session_id");
1978
+ expect(hasOld).toBe(true);
1979
+ expect(hasNew).toBe(false);
1980
+ });
1981
+
1982
+ test("idempotency: calling down twice does not throw", () => {
1983
+ const db = createTestDb();
1984
+ const raw = getRaw(db);
1985
+ raw.exec(
1986
+ `CREATE TABLE call_sessions (id TEXT PRIMARY KEY, verification_session_id TEXT, created_at INTEGER NOT NULL)`,
1987
+ );
1988
+
1989
+ downRenameVerificationSessionIdColumn(db);
1990
+ downRenameVerificationSessionIdColumn(db);
1991
+ });
1992
+ });
1993
+
1994
+ // ── v23: downRenameGuardianVerificationValues ───────────────────────
1995
+
1996
+ describe("v23: downRenameGuardianVerificationValues", () => {
1997
+ test("restores guardian_ prefix on call_mode and event_type values", () => {
1998
+ const db = createTestDb();
1999
+ const raw = getRaw(db);
2000
+
2001
+ raw.exec(/*sql*/ `
2002
+ CREATE TABLE call_sessions (id TEXT PRIMARY KEY, call_mode TEXT NOT NULL);
2003
+ CREATE TABLE call_events (id TEXT PRIMARY KEY, event_type TEXT NOT NULL);
2004
+ INSERT INTO call_sessions VALUES ('s1', 'verification');
2005
+ INSERT INTO call_events VALUES ('e1', 'voice_verification_started');
2006
+ INSERT INTO call_events VALUES ('e2', 'outbound_voice_verification_succeeded');
2007
+ `);
2008
+
2009
+ downRenameGuardianVerificationValues(db);
2010
+
2011
+ const session = raw
2012
+ .query(`SELECT call_mode FROM call_sessions WHERE id = 's1'`)
2013
+ .get() as { call_mode: string };
2014
+ expect(session.call_mode).toBe("guardian_verification");
2015
+
2016
+ const event1 = raw
2017
+ .query(`SELECT event_type FROM call_events WHERE id = 'e1'`)
2018
+ .get() as { event_type: string };
2019
+ expect(event1.event_type).toBe("guardian_voice_verification_started");
2020
+
2021
+ const event2 = raw
2022
+ .query(`SELECT event_type FROM call_events WHERE id = 'e2'`)
2023
+ .get() as { event_type: string };
2024
+ expect(event2.event_type).toBe(
2025
+ "outbound_guardian_voice_verification_succeeded",
2026
+ );
2027
+ });
2028
+
2029
+ test("idempotency: calling down twice does not throw", () => {
2030
+ const db = createTestDb();
2031
+ const raw = getRaw(db);
2032
+ raw.exec(
2033
+ `CREATE TABLE call_sessions (id TEXT PRIMARY KEY, call_mode TEXT NOT NULL)`,
2034
+ );
2035
+ raw.exec(
2036
+ `CREATE TABLE call_events (id TEXT PRIMARY KEY, event_type TEXT NOT NULL)`,
2037
+ );
2038
+ raw.exec(`INSERT INTO call_sessions VALUES ('s1', 'verification')`);
2039
+
2040
+ downRenameGuardianVerificationValues(db);
2041
+ downRenameGuardianVerificationValues(db);
2042
+ });
2043
+ });
2044
+
2045
+ // ── v24: downRenameVoiceToPhone (value rename) ──────────────────────
2046
+
2047
+ describe("v24: downRenameVoiceToPhone", () => {
2048
+ test("renames 'phone' values back to 'voice'", () => {
2049
+ const db = createTestDb();
2050
+ const raw = getRaw(db);
2051
+
2052
+ raw.exec(/*sql*/ `
2053
+ CREATE TABLE contact_channels (id TEXT PRIMARY KEY, type TEXT NOT NULL);
2054
+ CREATE TABLE conversations (id TEXT PRIMARY KEY, origin_channel TEXT, origin_interface TEXT);
2055
+ CREATE TABLE messages (id TEXT PRIMARY KEY, metadata TEXT);
2056
+ CREATE TABLE assistant_ingress_invites (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL);
2057
+ CREATE TABLE assistant_inbox_thread_state (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL);
2058
+ CREATE TABLE guardian_action_requests (id TEXT PRIMARY KEY, source_channel TEXT, answered_by_channel TEXT);
2059
+ CREATE TABLE channel_verification_sessions (id TEXT PRIMARY KEY, channel TEXT NOT NULL);
2060
+ CREATE TABLE channel_guardian_approval_requests (id TEXT PRIMARY KEY, channel TEXT NOT NULL);
2061
+ CREATE TABLE channel_guardian_rate_limits (id TEXT PRIMARY KEY, channel TEXT NOT NULL, actor_external_user_id TEXT, actor_chat_id TEXT);
2062
+ CREATE TABLE notification_events (id TEXT PRIMARY KEY, source_channel TEXT);
2063
+ CREATE TABLE notification_deliveries (id TEXT PRIMARY KEY, channel TEXT);
2064
+ CREATE TABLE external_conversation_bindings (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL);
2065
+ CREATE TABLE channel_inbound_events (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL);
2066
+ CREATE TABLE conversation_attention_events (id TEXT PRIMARY KEY, source_channel TEXT);
2067
+ CREATE TABLE conversation_assistant_attention_state (id TEXT PRIMARY KEY, last_seen_source_channel TEXT);
2068
+ CREATE TABLE canonical_guardian_requests (id TEXT PRIMARY KEY, source_channel TEXT);
2069
+ CREATE TABLE canonical_guardian_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL);
2070
+ CREATE TABLE guardian_action_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL);
2071
+ CREATE TABLE scoped_approval_grants (id TEXT PRIMARY KEY, request_channel TEXT NOT NULL, decision_channel TEXT NOT NULL, execution_channel TEXT);
2072
+ CREATE TABLE sequences (id TEXT PRIMARY KEY, channel TEXT);
2073
+ CREATE TABLE followups (id TEXT PRIMARY KEY, channel TEXT);
2074
+ `);
2075
+
2076
+ raw.exec(`INSERT INTO contact_channels VALUES ('cc1', 'phone')`);
2077
+ raw.exec(`INSERT INTO conversations VALUES ('c1', 'phone', 'phone')`);
2078
+
2079
+ downRenameVoiceToPhone(db);
2080
+
2081
+ const cc = raw
2082
+ .query(`SELECT type FROM contact_channels WHERE id = 'cc1'`)
2083
+ .get() as { type: string };
2084
+ expect(cc.type).toBe("voice");
2085
+
2086
+ const conv = raw
2087
+ .query(
2088
+ `SELECT origin_channel, origin_interface FROM conversations WHERE id = 'c1'`,
2089
+ )
2090
+ .get() as { origin_channel: string; origin_interface: string };
2091
+ expect(conv.origin_channel).toBe("voice");
2092
+ expect(conv.origin_interface).toBe("voice");
2093
+ });
2094
+
2095
+ test("idempotency: calling down twice does not throw", () => {
2096
+ const db = createTestDb();
2097
+ const raw = getRaw(db);
2098
+ raw.exec(
2099
+ `CREATE TABLE contact_channels (id TEXT PRIMARY KEY, type TEXT NOT NULL)`,
2100
+ );
2101
+ raw.exec(
2102
+ `CREATE TABLE conversations (id TEXT PRIMARY KEY, origin_channel TEXT, origin_interface TEXT)`,
2103
+ );
2104
+ raw.exec(`CREATE TABLE messages (id TEXT PRIMARY KEY, metadata TEXT)`);
2105
+ raw.exec(
2106
+ `CREATE TABLE assistant_ingress_invites (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL)`,
2107
+ );
2108
+ raw.exec(
2109
+ `CREATE TABLE assistant_inbox_thread_state (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL)`,
2110
+ );
2111
+ raw.exec(
2112
+ `CREATE TABLE guardian_action_requests (id TEXT PRIMARY KEY, source_channel TEXT, answered_by_channel TEXT)`,
2113
+ );
2114
+ raw.exec(
2115
+ `CREATE TABLE channel_verification_sessions (id TEXT PRIMARY KEY, channel TEXT NOT NULL)`,
2116
+ );
2117
+ raw.exec(
2118
+ `CREATE TABLE channel_guardian_approval_requests (id TEXT PRIMARY KEY, channel TEXT NOT NULL)`,
2119
+ );
2120
+ raw.exec(
2121
+ `CREATE TABLE channel_guardian_rate_limits (id TEXT PRIMARY KEY, channel TEXT NOT NULL, actor_external_user_id TEXT, actor_chat_id TEXT)`,
2122
+ );
2123
+ raw.exec(
2124
+ `CREATE TABLE notification_events (id TEXT PRIMARY KEY, source_channel TEXT)`,
2125
+ );
2126
+ raw.exec(
2127
+ `CREATE TABLE notification_deliveries (id TEXT PRIMARY KEY, channel TEXT)`,
2128
+ );
2129
+ raw.exec(
2130
+ `CREATE TABLE external_conversation_bindings (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL)`,
2131
+ );
2132
+ raw.exec(
2133
+ `CREATE TABLE channel_inbound_events (id TEXT PRIMARY KEY, source_channel TEXT NOT NULL)`,
2134
+ );
2135
+ raw.exec(
2136
+ `CREATE TABLE conversation_attention_events (id TEXT PRIMARY KEY, source_channel TEXT)`,
2137
+ );
2138
+ raw.exec(
2139
+ `CREATE TABLE conversation_assistant_attention_state (id TEXT PRIMARY KEY, last_seen_source_channel TEXT)`,
2140
+ );
2141
+ raw.exec(
2142
+ `CREATE TABLE canonical_guardian_requests (id TEXT PRIMARY KEY, source_channel TEXT)`,
2143
+ );
2144
+ raw.exec(
2145
+ `CREATE TABLE canonical_guardian_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL)`,
2146
+ );
2147
+ raw.exec(
2148
+ `CREATE TABLE guardian_action_deliveries (id TEXT PRIMARY KEY, destination_channel TEXT NOT NULL)`,
2149
+ );
2150
+ raw.exec(
2151
+ `CREATE TABLE scoped_approval_grants (id TEXT PRIMARY KEY, request_channel TEXT NOT NULL, decision_channel TEXT NOT NULL, execution_channel TEXT)`,
2152
+ );
2153
+ raw.exec(`CREATE TABLE sequences (id TEXT PRIMARY KEY, channel TEXT)`);
2154
+ raw.exec(`CREATE TABLE followups (id TEXT PRIMARY KEY, channel TEXT)`);
2155
+
2156
+ downRenameVoiceToPhone(db);
2157
+ downRenameVoiceToPhone(db);
2158
+ });
2159
+ });
2160
+
2161
+ // ── v25: migrateDropAccountsTableDown (table recreation) ────────────
2162
+
2163
+ describe("v25: migrateDropAccountsTableDown", () => {
2164
+ test("recreates the accounts table with correct schema", () => {
2165
+ const db = createTestDb();
2166
+ const raw = getRaw(db);
2167
+
2168
+ migrateDropAccountsTableDown(db);
2169
+
2170
+ const table = raw
2171
+ .query(
2172
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts'`,
2173
+ )
2174
+ .get();
2175
+ expect(table).toBeTruthy();
2176
+
2177
+ // Check indexes.
2178
+ const idxService = raw
2179
+ .query(
2180
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_accounts_service'`,
2181
+ )
2182
+ .get();
2183
+ expect(idxService).toBeTruthy();
2184
+
2185
+ const idxStatus = raw
2186
+ .query(
2187
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_accounts_status'`,
2188
+ )
2189
+ .get();
2190
+ expect(idxStatus).toBeTruthy();
2191
+ });
2192
+
2193
+ test("idempotency: calling down twice does not throw", () => {
2194
+ const db = createTestDb();
2195
+ migrateDropAccountsTableDown(db);
2196
+ migrateDropAccountsTableDown(db);
2197
+ });
2198
+ });
2199
+
2200
+ // ── v27: migrateDropRemindersTableDown (table recreation) ───────────
2201
+
2202
+ describe("v27: migrateDropRemindersTableDown", () => {
2203
+ test("recreates the reminders table with correct schema", () => {
2204
+ const db = createTestDb();
2205
+ const raw = getRaw(db);
2206
+
2207
+ migrateDropRemindersTableDown(db);
2208
+
2209
+ const table = raw
2210
+ .query(
2211
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'reminders'`,
2212
+ )
2213
+ .get();
2214
+ expect(table).toBeTruthy();
2215
+
2216
+ // Verify index.
2217
+ const idx = raw
2218
+ .query(
2219
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_reminders_status_fire_at'`,
2220
+ )
2221
+ .get();
2222
+ expect(idx).toBeTruthy();
2223
+
2224
+ // Verify columns include routing_intent and routing_hints_json.
2225
+ const cols = raw.query(`PRAGMA table_info(reminders)`).all() as Array<{
2226
+ name: string;
2227
+ }>;
2228
+ const colNames = cols.map((c) => c.name);
2229
+ expect(colNames).toContain("routing_intent");
2230
+ expect(colNames).toContain("routing_hints_json");
2231
+ });
2232
+
2233
+ test("idempotency: calling down twice does not throw", () => {
2234
+ const db = createTestDb();
2235
+ migrateDropRemindersTableDown(db);
2236
+ migrateDropRemindersTableDown(db);
2237
+ });
2238
+ });
2239
+
2240
+ // ── v28: migrateOAuthAppsClientSecretPathDown (column drop) ─────────
2241
+
2242
+ describe("v28: migrateOAuthAppsClientSecretPathDown", () => {
2243
+ test("drops client_secret_credential_path column from oauth_apps", () => {
2244
+ const db = createTestDb();
2245
+ const raw = getRaw(db);
2246
+
2247
+ raw.exec(/*sql*/ `
2248
+ CREATE TABLE oauth_providers (provider_key TEXT PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
2249
+ CREATE TABLE oauth_apps (
2250
+ id TEXT PRIMARY KEY,
2251
+ provider_key TEXT NOT NULL REFERENCES oauth_providers(provider_key),
2252
+ client_id TEXT NOT NULL,
2253
+ client_secret_credential_path TEXT,
2254
+ created_at INTEGER NOT NULL,
2255
+ updated_at INTEGER NOT NULL
2256
+ );
2257
+ `);
2258
+
2259
+ migrateOAuthAppsClientSecretPathDown(db);
2260
+
2261
+ const col = raw
2262
+ .query(
2263
+ `SELECT 1 FROM pragma_table_info('oauth_apps') WHERE name = 'client_secret_credential_path'`,
2264
+ )
2265
+ .get();
2266
+ expect(col).toBeNull();
2267
+ });
2268
+
2269
+ test("idempotency: calling down twice does not throw", () => {
2270
+ const db = createTestDb();
2271
+ const raw = getRaw(db);
2272
+ raw.exec(
2273
+ `CREATE TABLE oauth_providers (provider_key TEXT PRIMARY KEY, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2274
+ );
2275
+ raw.exec(
2276
+ `CREATE TABLE oauth_apps (id TEXT PRIMARY KEY, provider_key TEXT NOT NULL, client_id TEXT NOT NULL, client_secret_credential_path TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2277
+ );
2278
+
2279
+ migrateOAuthAppsClientSecretPathDown(db);
2280
+ migrateOAuthAppsClientSecretPathDown(db);
2281
+ });
2282
+ });
2283
+
2284
+ // ── v31: migrateRenameGmailProviderKeyToGoogleDown ──────────────────
2285
+
2286
+ describe("v31: migrateRenameGmailProviderKeyToGoogleDown", () => {
2287
+ test("renames integration:google back to integration:gmail", () => {
2288
+ const db = createTestDb();
2289
+ const raw = getRaw(db);
2290
+
2291
+ raw.exec(/*sql*/ `
2292
+ CREATE TABLE oauth_providers (provider_key TEXT PRIMARY KEY);
2293
+ CREATE TABLE oauth_apps (id TEXT PRIMARY KEY, provider_key TEXT NOT NULL);
2294
+ CREATE TABLE oauth_connections (id TEXT PRIMARY KEY, provider_key TEXT NOT NULL);
2295
+ INSERT INTO oauth_providers VALUES ('integration:google');
2296
+ INSERT INTO oauth_apps VALUES ('app1', 'integration:google');
2297
+ INSERT INTO oauth_connections VALUES ('conn1', 'integration:google');
2298
+ `);
2299
+
2300
+ migrateRenameGmailProviderKeyToGoogleDown(db);
2301
+
2302
+ const provider = raw
2303
+ .query(
2304
+ `SELECT provider_key FROM oauth_providers WHERE provider_key = 'integration:gmail'`,
2305
+ )
2306
+ .get();
2307
+ expect(provider).toBeTruthy();
2308
+
2309
+ const app = raw
2310
+ .query(`SELECT provider_key FROM oauth_apps WHERE id = 'app1'`)
2311
+ .get() as { provider_key: string };
2312
+ expect(app.provider_key).toBe("integration:gmail");
2313
+ });
2314
+
2315
+ test("idempotency: calling down twice does not throw", () => {
2316
+ const db = createTestDb();
2317
+ const raw = getRaw(db);
2318
+ raw.exec(`CREATE TABLE oauth_providers (provider_key TEXT PRIMARY KEY)`);
2319
+ raw.exec(
2320
+ `CREATE TABLE oauth_apps (id TEXT PRIMARY KEY, provider_key TEXT NOT NULL)`,
2321
+ );
2322
+ raw.exec(
2323
+ `CREATE TABLE oauth_connections (id TEXT PRIMARY KEY, provider_key TEXT NOT NULL)`,
2324
+ );
2325
+ raw.exec(`INSERT INTO oauth_providers VALUES ('integration:google')`);
2326
+
2327
+ migrateRenameGmailProviderKeyToGoogleDown(db);
2328
+ migrateRenameGmailProviderKeyToGoogleDown(db);
2329
+ });
2330
+ });
2331
+
2332
+ // ── v32: migrateRenameThreadStartersTableDown ───────────────────────
2333
+
2334
+ describe("v32: migrateRenameThreadStartersTableDown", () => {
2335
+ test("renames conversation_starters back to thread_starters", () => {
2336
+ const db = createTestDb();
2337
+ const raw = getRaw(db);
2338
+
2339
+ raw.exec(/*sql*/ `
2340
+ CREATE TABLE conversation_starters (
2341
+ id TEXT PRIMARY KEY,
2342
+ generation_batch TEXT,
2343
+ card_type TEXT,
2344
+ scope_id TEXT,
2345
+ created_at INTEGER NOT NULL
2346
+ )
2347
+ `);
2348
+ raw.exec(
2349
+ `CREATE INDEX idx_conversation_starters_batch ON conversation_starters(generation_batch, created_at)`,
2350
+ );
2351
+ raw.exec(
2352
+ `CREATE INDEX idx_conversation_starters_card_type ON conversation_starters(card_type, scope_id)`,
2353
+ );
2354
+
2355
+ migrateRenameThreadStartersTableDown(db);
2356
+
2357
+ const oldTable = raw
2358
+ .query(
2359
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'thread_starters'`,
2360
+ )
2361
+ .get();
2362
+ expect(oldTable).toBeTruthy();
2363
+
2364
+ const newTable = raw
2365
+ .query(
2366
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'conversation_starters'`,
2367
+ )
2368
+ .get();
2369
+ expect(newTable).toBeNull();
2370
+
2371
+ // Old-style indexes should exist.
2372
+ const batchIdx = raw
2373
+ .query(
2374
+ `SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_thread_starters_batch'`,
2375
+ )
2376
+ .get();
2377
+ expect(batchIdx).toBeTruthy();
2378
+ });
2379
+
2380
+ test("idempotency: calling down twice does not throw", () => {
2381
+ const db = createTestDb();
2382
+ const raw = getRaw(db);
2383
+ raw.exec(
2384
+ `CREATE TABLE conversation_starters (id TEXT PRIMARY KEY, generation_batch TEXT, card_type TEXT, scope_id TEXT, created_at INTEGER NOT NULL)`,
2385
+ );
2386
+
2387
+ migrateRenameThreadStartersTableDown(db);
2388
+ migrateRenameThreadStartersTableDown(db);
2389
+ });
2390
+ });
2391
+
2392
+ // ── v35: migrateRenameThreadStartersCheckpointsDown ─────────────────
2393
+
2394
+ describe("v35: migrateRenameThreadStartersCheckpointsDown", () => {
2395
+ test("renames conversation_starters: checkpoint keys back to thread_starters:", () => {
2396
+ const db = createTestDb();
2397
+ const raw = getRaw(db);
2398
+ bootstrapCheckpointsTable(raw);
2399
+
2400
+ const now = Date.now();
2401
+ raw.exec(
2402
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('conversation_starters:gen_batch_1', '1', ${now})`,
2403
+ );
2404
+ raw.exec(
2405
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('conversation_starters:gen_batch_2', '1', ${now})`,
2406
+ );
2407
+
2408
+ migrateRenameThreadStartersCheckpointsDown(db);
2409
+
2410
+ const newPrefixCount = (
2411
+ raw
2412
+ .query(
2413
+ `SELECT COUNT(*) AS c FROM memory_checkpoints WHERE key LIKE 'conversation_starters:%'`,
2414
+ )
2415
+ .get() as { c: number }
2416
+ ).c;
2417
+ expect(newPrefixCount).toBe(0);
2418
+
2419
+ const oldPrefixCount = (
2420
+ raw
2421
+ .query(
2422
+ `SELECT COUNT(*) AS c FROM memory_checkpoints WHERE key LIKE 'thread_starters:%'`,
2423
+ )
2424
+ .get() as { c: number }
2425
+ ).c;
2426
+ expect(oldPrefixCount).toBe(2);
2427
+ });
2428
+
2429
+ test("idempotency: calling down twice does not throw", () => {
2430
+ const db = createTestDb();
2431
+ const raw = getRaw(db);
2432
+ bootstrapCheckpointsTable(raw);
2433
+
2434
+ const now = Date.now();
2435
+ raw.exec(
2436
+ `INSERT INTO memory_checkpoints (key, value, updated_at) VALUES ('conversation_starters:gen_batch_1', '1', ${now})`,
2437
+ );
2438
+
2439
+ migrateRenameThreadStartersCheckpointsDown(db);
2440
+ migrateRenameThreadStartersCheckpointsDown(db);
2441
+ });
2442
+ });
2443
+
2444
+ // ── v18: downBackfillContactInteractionStats ────────────────────────
2445
+
2446
+ describe("v18: downBackfillContactInteractionStats", () => {
2447
+ test("clears last_interaction column", () => {
2448
+ const db = createTestDb();
2449
+ const raw = getRaw(db);
2450
+
2451
+ raw.exec(/*sql*/ `
2452
+ CREATE TABLE contacts (
2453
+ id TEXT PRIMARY KEY,
2454
+ last_interaction INTEGER,
2455
+ created_at INTEGER NOT NULL,
2456
+ updated_at INTEGER NOT NULL
2457
+ )
2458
+ `);
2459
+
2460
+ const now = Date.now();
2461
+ raw.exec(`INSERT INTO contacts VALUES ('c1', ${now}, ${now}, ${now})`);
2462
+
2463
+ downBackfillContactInteractionStats(db);
2464
+
2465
+ const contact = raw
2466
+ .query(`SELECT last_interaction FROM contacts WHERE id = 'c1'`)
2467
+ .get() as { last_interaction: number | null };
2468
+ expect(contact.last_interaction).toBeNull();
2469
+ });
2470
+
2471
+ test("idempotency: calling down twice does not throw", () => {
2472
+ const db = createTestDb();
2473
+ const raw = getRaw(db);
2474
+ raw.exec(
2475
+ `CREATE TABLE contacts (id TEXT PRIMARY KEY, last_interaction INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2476
+ );
2477
+ raw.exec(
2478
+ `INSERT INTO contacts VALUES ('c1', ${Date.now()}, ${Date.now()}, ${Date.now()})`,
2479
+ );
2480
+
2481
+ downBackfillContactInteractionStats(db);
2482
+ downBackfillContactInteractionStats(db);
2483
+ });
2484
+ });
2485
+
2486
+ // ── v12: downEmbeddingVectorBlob (column drop) ──────────────────────
2487
+
2488
+ describe("v12: downEmbeddingVectorBlob", () => {
2489
+ test("drops vector_blob column from memory_embeddings", () => {
2490
+ const db = createTestDb();
2491
+ const raw = getRaw(db);
2492
+
2493
+ raw.exec(/*sql*/ `
2494
+ CREATE TABLE memory_embeddings (
2495
+ id TEXT PRIMARY KEY,
2496
+ target_type TEXT NOT NULL,
2497
+ target_id TEXT NOT NULL,
2498
+ provider TEXT NOT NULL,
2499
+ model TEXT NOT NULL,
2500
+ dimensions INTEGER NOT NULL,
2501
+ vector_json TEXT,
2502
+ vector_blob BLOB,
2503
+ content_hash TEXT,
2504
+ created_at INTEGER NOT NULL,
2505
+ updated_at INTEGER NOT NULL,
2506
+ UNIQUE (target_type, target_id, provider, model)
2507
+ )
2508
+ `);
2509
+
2510
+ downEmbeddingVectorBlob(db);
2511
+
2512
+ const col = raw
2513
+ .query(
2514
+ `SELECT 1 FROM pragma_table_info('memory_embeddings') WHERE name = 'vector_blob'`,
2515
+ )
2516
+ .get();
2517
+ expect(col).toBeNull();
2518
+
2519
+ // Other columns should still exist.
2520
+ const vectorJson = raw
2521
+ .query(
2522
+ `SELECT 1 FROM pragma_table_info('memory_embeddings') WHERE name = 'vector_json'`,
2523
+ )
2524
+ .get();
2525
+ expect(vectorJson).toBeTruthy();
2526
+ });
2527
+
2528
+ test("idempotency: calling down twice does not throw", () => {
2529
+ const db = createTestDb();
2530
+ const raw = getRaw(db);
2531
+ raw.exec(/*sql*/ `
2532
+ CREATE TABLE memory_embeddings (
2533
+ id TEXT PRIMARY KEY,
2534
+ target_type TEXT NOT NULL,
2535
+ target_id TEXT NOT NULL,
2536
+ provider TEXT NOT NULL,
2537
+ model TEXT NOT NULL,
2538
+ dimensions INTEGER NOT NULL,
2539
+ vector_json TEXT,
2540
+ vector_blob BLOB,
2541
+ content_hash TEXT,
2542
+ created_at INTEGER NOT NULL,
2543
+ updated_at INTEGER NOT NULL
2544
+ )
2545
+ `);
2546
+
2547
+ downEmbeddingVectorBlob(db);
2548
+ downEmbeddingVectorBlob(db);
2549
+ });
2550
+ });
2551
+
2552
+ // ── v13: downEmbeddingsNullableVectorJson ───────────────────────────
2553
+
2554
+ describe("v13: downEmbeddingsNullableVectorJson", () => {
2555
+ test("restores NOT NULL on vector_json column", () => {
2556
+ const db = createTestDb();
2557
+ const raw = getRaw(db);
2558
+
2559
+ // Post-forward-migration schema: vector_json is nullable.
2560
+ raw.exec(/*sql*/ `
2561
+ CREATE TABLE memory_embeddings (
2562
+ id TEXT PRIMARY KEY,
2563
+ target_type TEXT NOT NULL,
2564
+ target_id TEXT NOT NULL,
2565
+ provider TEXT NOT NULL,
2566
+ model TEXT NOT NULL,
2567
+ dimensions INTEGER NOT NULL,
2568
+ vector_json TEXT,
2569
+ vector_blob BLOB,
2570
+ content_hash TEXT,
2571
+ created_at INTEGER NOT NULL,
2572
+ updated_at INTEGER NOT NULL,
2573
+ UNIQUE (target_type, target_id, provider, model)
2574
+ )
2575
+ `);
2576
+
2577
+ const now = Date.now();
2578
+ raw.exec(
2579
+ `INSERT INTO memory_embeddings VALUES ('e1', 'item', 'item-1', 'openai', 'text-embedding-3-small', 1536, '[0.1,0.2]', NULL, 'hash1', ${now}, ${now})`,
2580
+ );
2581
+
2582
+ downEmbeddingsNullableVectorJson(db);
2583
+
2584
+ // Check that vector_json is now NOT NULL.
2585
+ const ddl =
2586
+ (
2587
+ raw
2588
+ .query(
2589
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'memory_embeddings'`,
2590
+ )
2591
+ .get() as { sql: string }
2592
+ )?.sql ?? "";
2593
+ expect(ddl).toMatch(/vector_json\s+TEXT\s+NOT\s+NULL/i);
2594
+
2595
+ // Data with non-null vector_json should be preserved.
2596
+ const row = raw
2597
+ .query(`SELECT id FROM memory_embeddings WHERE id = 'e1'`)
2598
+ .get();
2599
+ expect(row).toBeTruthy();
2600
+ });
2601
+
2602
+ test("idempotency: calling down twice does not throw", () => {
2603
+ const db = createTestDb();
2604
+ const raw = getRaw(db);
2605
+ raw.exec(/*sql*/ `
2606
+ CREATE TABLE memory_embeddings (
2607
+ id TEXT PRIMARY KEY, target_type TEXT NOT NULL, target_id TEXT NOT NULL,
2608
+ provider TEXT NOT NULL, model TEXT NOT NULL, dimensions INTEGER NOT NULL,
2609
+ vector_json TEXT, vector_blob BLOB, content_hash TEXT,
2610
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
2611
+ UNIQUE (target_type, target_id, provider, model)
2612
+ )
2613
+ `);
2614
+
2615
+ downEmbeddingsNullableVectorJson(db);
2616
+ downEmbeddingsNullableVectorJson(db);
2617
+ });
2618
+ });
2619
+
2620
+ // ── v15: downBackfillGuardianPrincipalId ─────────────────────────────
2621
+
2622
+ describe("v15: downBackfillGuardianPrincipalId", () => {
2623
+ test("nulls out guardian_principal_id on channel_guardian_bindings", () => {
2624
+ const db = createTestDb();
2625
+ const raw = getRaw(db);
2626
+
2627
+ raw.exec(/*sql*/ `
2628
+ CREATE TABLE channel_guardian_bindings (
2629
+ id TEXT PRIMARY KEY,
2630
+ assistant_id TEXT NOT NULL,
2631
+ channel TEXT NOT NULL,
2632
+ guardian_external_user_id TEXT NOT NULL,
2633
+ guardian_delivery_chat_id TEXT NOT NULL,
2634
+ guardian_principal_id TEXT,
2635
+ status TEXT NOT NULL DEFAULT 'active',
2636
+ verified_at INTEGER NOT NULL,
2637
+ verified_via TEXT NOT NULL DEFAULT 'challenge',
2638
+ metadata_json TEXT,
2639
+ created_at INTEGER NOT NULL,
2640
+ updated_at INTEGER NOT NULL
2641
+ )
2642
+ `);
2643
+
2644
+ const now = Date.now();
2645
+ raw.exec(
2646
+ `INSERT INTO channel_guardian_bindings VALUES ('b1', 'self', 'vellum', 'user1', 'chat1', 'principal1', 'active', ${now}, 'challenge', NULL, ${now}, ${now})`,
2647
+ );
2648
+
2649
+ downBackfillGuardianPrincipalId(db);
2650
+
2651
+ const row = raw
2652
+ .query(
2653
+ `SELECT guardian_principal_id FROM channel_guardian_bindings WHERE id = 'b1'`,
2654
+ )
2655
+ .get() as { guardian_principal_id: string | null };
2656
+ expect(row.guardian_principal_id).toBeNull();
2657
+ });
2658
+
2659
+ test("idempotency: calling down twice does not throw", () => {
2660
+ const db = createTestDb();
2661
+ const raw = getRaw(db);
2662
+ raw.exec(
2663
+ `CREATE TABLE channel_guardian_bindings (id TEXT PRIMARY KEY, guardian_principal_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2664
+ );
2665
+
2666
+ downBackfillGuardianPrincipalId(db);
2667
+ downBackfillGuardianPrincipalId(db);
2668
+ });
2669
+ });
2670
+
2671
+ // ── v16: downGuardianPrincipalIdNotNull ──────────────────────────────
2672
+
2673
+ describe("v16: downGuardianPrincipalIdNotNull", () => {
2674
+ test("makes guardian_principal_id nullable again", () => {
2675
+ const db = createTestDb();
2676
+ const raw = getRaw(db);
2677
+
2678
+ raw.exec(/*sql*/ `
2679
+ CREATE TABLE channel_guardian_bindings (
2680
+ id TEXT PRIMARY KEY,
2681
+ assistant_id TEXT NOT NULL,
2682
+ channel TEXT NOT NULL,
2683
+ guardian_external_user_id TEXT NOT NULL,
2684
+ guardian_delivery_chat_id TEXT NOT NULL,
2685
+ guardian_principal_id TEXT NOT NULL,
2686
+ status TEXT NOT NULL DEFAULT 'active',
2687
+ verified_at INTEGER NOT NULL,
2688
+ verified_via TEXT NOT NULL DEFAULT 'challenge',
2689
+ metadata_json TEXT,
2690
+ created_at INTEGER NOT NULL,
2691
+ updated_at INTEGER NOT NULL
2692
+ )
2693
+ `);
2694
+
2695
+ // Confirm NOT NULL before down.
2696
+ const colBefore = raw
2697
+ .query(
2698
+ `SELECT "notnull" FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
2699
+ )
2700
+ .get() as { notnull: number };
2701
+ expect(colBefore.notnull).toBe(1);
2702
+
2703
+ downGuardianPrincipalIdNotNull(db);
2704
+
2705
+ // After down, should be nullable.
2706
+ const colAfter = raw
2707
+ .query(
2708
+ `SELECT "notnull" FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
2709
+ )
2710
+ .get() as { notnull: number };
2711
+ expect(colAfter.notnull).toBe(0);
2712
+ });
2713
+
2714
+ test("idempotency: calling down twice does not throw", () => {
2715
+ const db = createTestDb();
2716
+ const raw = getRaw(db);
2717
+ raw.exec(/*sql*/ `
2718
+ CREATE TABLE channel_guardian_bindings (
2719
+ id TEXT PRIMARY KEY, assistant_id TEXT NOT NULL, channel TEXT NOT NULL,
2720
+ guardian_external_user_id TEXT NOT NULL, guardian_delivery_chat_id TEXT NOT NULL,
2721
+ guardian_principal_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
2722
+ verified_at INTEGER NOT NULL, verified_via TEXT NOT NULL DEFAULT 'challenge',
2723
+ metadata_json TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2724
+ )
2725
+ `);
2726
+
2727
+ downGuardianPrincipalIdNotNull(db);
2728
+ downGuardianPrincipalIdNotNull(db);
2729
+ });
2730
+ });
2731
+
2732
+ // ── v29: migrateGuardianTimestampsEpochMsDown ───────────────────────
2733
+
2734
+ describe("v29: migrateGuardianTimestampsEpochMsDown", () => {
2735
+ test("converts epoch ms integers back to ISO 8601 strings", () => {
2736
+ const db = createTestDb();
2737
+ const raw = getRaw(db);
2738
+
2739
+ raw.exec(/*sql*/ `
2740
+ CREATE TABLE canonical_guardian_requests (
2741
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_type TEXT NOT NULL,
2742
+ source_channel TEXT, status TEXT NOT NULL DEFAULT 'pending',
2743
+ expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2744
+ );
2745
+ CREATE TABLE canonical_guardian_deliveries (
2746
+ id TEXT PRIMARY KEY, request_id TEXT NOT NULL, destination_channel TEXT NOT NULL,
2747
+ status TEXT NOT NULL DEFAULT 'pending', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2748
+ );
2749
+ CREATE TABLE scoped_approval_grants (
2750
+ id TEXT PRIMARY KEY, scope_mode TEXT NOT NULL, request_channel TEXT NOT NULL,
2751
+ decision_channel TEXT NOT NULL, status TEXT NOT NULL,
2752
+ expires_at INTEGER NOT NULL, consumed_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2753
+ );
2754
+ `);
2755
+
2756
+ // Insert with epoch ms values (post-forward-migration state).
2757
+ const epochMs = 1700000000000; // 2023-11-14T22:13:20.000Z
2758
+ raw.exec(
2759
+ `INSERT INTO canonical_guardian_requests VALUES ('r1', 'approval', 'desktop', 'vellum', 'pending', NULL, ${epochMs}, ${epochMs})`,
2760
+ );
2761
+ raw.exec(
2762
+ `INSERT INTO canonical_guardian_deliveries VALUES ('d1', 'r1', 'vellum', 'pending', ${epochMs}, ${epochMs})`,
2763
+ );
2764
+ raw.exec(
2765
+ `INSERT INTO scoped_approval_grants VALUES ('g1', 'once', 'vellum', 'vellum', 'active', ${epochMs}, NULL, ${epochMs}, ${epochMs})`,
2766
+ );
2767
+
2768
+ migrateGuardianTimestampsEpochMsDown(db);
2769
+
2770
+ // Verify created_at is now a text ISO 8601 string.
2771
+ const req = raw
2772
+ .query(
2773
+ `SELECT created_at, typeof(created_at) AS t FROM canonical_guardian_requests WHERE id = 'r1'`,
2774
+ )
2775
+ .get() as { created_at: string; t: string };
2776
+ expect(req.t).toBe("text");
2777
+ expect(req.created_at).toContain("2023-11-14");
2778
+ });
2779
+
2780
+ test("idempotency: calling down twice does not throw", () => {
2781
+ const db = createTestDb();
2782
+ const raw = getRaw(db);
2783
+ raw.exec(
2784
+ `CREATE TABLE canonical_guardian_requests (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_type TEXT NOT NULL, source_channel TEXT, status TEXT NOT NULL, expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2785
+ );
2786
+ raw.exec(
2787
+ `CREATE TABLE canonical_guardian_deliveries (id TEXT PRIMARY KEY, request_id TEXT NOT NULL, destination_channel TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2788
+ );
2789
+ raw.exec(
2790
+ `CREATE TABLE scoped_approval_grants (id TEXT PRIMARY KEY, scope_mode TEXT NOT NULL, request_channel TEXT NOT NULL, decision_channel TEXT NOT NULL, status TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
2791
+ );
2792
+
2793
+ const epochMs = 1700000000000;
2794
+ raw.exec(
2795
+ `INSERT INTO canonical_guardian_requests VALUES ('r1', 'approval', 'desktop', 'vellum', 'pending', NULL, ${epochMs}, ${epochMs})`,
2796
+ );
2797
+
2798
+ migrateGuardianTimestampsEpochMsDown(db);
2799
+ // Second call — values are already text, typeof check skips them.
2800
+ migrateGuardianTimestampsEpochMsDown(db);
2801
+ });
2802
+ });
2803
+
2804
+ // ── v30: migrateGuardianTimestampsRebuildDown ───────────────────────
2805
+
2806
+ describe("v30: migrateGuardianTimestampsRebuildDown", () => {
2807
+ test("rebuilds tables with TEXT affinity on timestamp columns", () => {
2808
+ const db = createTestDb();
2809
+ const raw = getRaw(db);
2810
+
2811
+ // Post-forward-migration state: INTEGER affinity on timestamp columns.
2812
+ raw.exec(/*sql*/ `
2813
+ CREATE TABLE canonical_guardian_requests (
2814
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_type TEXT NOT NULL,
2815
+ source_channel TEXT, conversation_id TEXT, requester_external_user_id TEXT,
2816
+ requester_chat_id TEXT, guardian_external_user_id TEXT, guardian_principal_id TEXT,
2817
+ call_session_id TEXT, pending_question_id TEXT, question_text TEXT,
2818
+ request_code TEXT, tool_name TEXT, input_digest TEXT,
2819
+ status TEXT NOT NULL DEFAULT 'pending', answer_text TEXT,
2820
+ decided_by_external_user_id TEXT, decided_by_principal_id TEXT,
2821
+ followup_state TEXT, expires_at INTEGER,
2822
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2823
+ );
2824
+ CREATE TABLE canonical_guardian_deliveries (
2825
+ id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES canonical_guardian_requests(id) ON DELETE CASCADE,
2826
+ destination_channel TEXT NOT NULL, destination_conversation_id TEXT,
2827
+ destination_chat_id TEXT, destination_message_id TEXT,
2828
+ status TEXT NOT NULL DEFAULT 'pending',
2829
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2830
+ );
2831
+ CREATE TABLE scoped_approval_grants (
2832
+ id TEXT PRIMARY KEY, scope_mode TEXT NOT NULL, request_id TEXT,
2833
+ tool_name TEXT, input_digest TEXT, request_channel TEXT NOT NULL,
2834
+ decision_channel TEXT NOT NULL, execution_channel TEXT,
2835
+ conversation_id TEXT, call_session_id TEXT,
2836
+ requester_external_user_id TEXT, guardian_external_user_id TEXT,
2837
+ status TEXT NOT NULL, expires_at INTEGER NOT NULL,
2838
+ consumed_at INTEGER, consumed_by_request_id TEXT,
2839
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2840
+ );
2841
+ `);
2842
+
2843
+ migrateGuardianTimestampsRebuildDown(db);
2844
+
2845
+ // Verify TEXT affinity on created_at.
2846
+ const colType = raw
2847
+ .query(
2848
+ `SELECT type FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'created_at'`,
2849
+ )
2850
+ .get() as { type: string };
2851
+ expect(colType.type.toUpperCase()).toBe("TEXT");
2852
+ });
2853
+
2854
+ test("idempotency: calling down twice does not throw", () => {
2855
+ const db = createTestDb();
2856
+ const raw = getRaw(db);
2857
+
2858
+ raw.exec(/*sql*/ `
2859
+ CREATE TABLE canonical_guardian_requests (
2860
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_type TEXT NOT NULL,
2861
+ source_channel TEXT, conversation_id TEXT, requester_external_user_id TEXT,
2862
+ requester_chat_id TEXT, guardian_external_user_id TEXT, guardian_principal_id TEXT,
2863
+ call_session_id TEXT, pending_question_id TEXT, question_text TEXT,
2864
+ request_code TEXT, tool_name TEXT, input_digest TEXT,
2865
+ status TEXT NOT NULL DEFAULT 'pending', answer_text TEXT,
2866
+ decided_by_external_user_id TEXT, decided_by_principal_id TEXT,
2867
+ followup_state TEXT, expires_at INTEGER,
2868
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2869
+ );
2870
+ CREATE TABLE canonical_guardian_deliveries (
2871
+ id TEXT PRIMARY KEY, request_id TEXT NOT NULL REFERENCES canonical_guardian_requests(id) ON DELETE CASCADE,
2872
+ destination_channel TEXT NOT NULL, destination_conversation_id TEXT,
2873
+ destination_chat_id TEXT, destination_message_id TEXT,
2874
+ status TEXT NOT NULL DEFAULT 'pending',
2875
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2876
+ );
2877
+ CREATE TABLE scoped_approval_grants (
2878
+ id TEXT PRIMARY KEY, scope_mode TEXT NOT NULL, request_id TEXT,
2879
+ tool_name TEXT, input_digest TEXT, request_channel TEXT NOT NULL,
2880
+ decision_channel TEXT NOT NULL, execution_channel TEXT,
2881
+ conversation_id TEXT, call_session_id TEXT,
2882
+ requester_external_user_id TEXT, guardian_external_user_id TEXT,
2883
+ status TEXT NOT NULL, expires_at INTEGER NOT NULL,
2884
+ consumed_at INTEGER, consumed_by_request_id TEXT,
2885
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
2886
+ );
2887
+ `);
2888
+
2889
+ migrateGuardianTimestampsRebuildDown(db);
2890
+ migrateGuardianTimestampsRebuildDown(db);
2891
+ });
2892
+ });
2893
+ });