@vellumai/assistant 0.10.3 → 0.10.4-staging.1

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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -37,6 +37,12 @@ const gatewayIpc = {
37
37
  mirrored: boolean;
38
38
  },
39
39
  claimThrows: false,
40
+ // When set, contacts_get_rich throws (gateway read unreachable) so the
41
+ // gate-status fallback must fail open.
42
+ richThrows: false,
43
+ // When set, overrides the contacts_get_rich response (e.g. a gateway row
44
+ // under a divergent UUID for the same (type,address)).
45
+ richOverride: null as ((contactId: string | undefined) => unknown) | null,
40
46
  // Drives the upsert_verified_channel relay verdict. When false the gateway
41
47
  // refuses the actor (blocked/revoked) and the activation is refused.
42
48
  activationVerified: true,
@@ -52,6 +58,13 @@ mock.module("../ipc/gateway-client.js", () => ({
52
58
  params?: Record<string, unknown>,
53
59
  ) => {
54
60
  gatewayIpc.calls.push({ method, params });
61
+ if (method === "contacts_get_rich") {
62
+ if (gatewayIpc.richThrows) throw new Error("gateway read unreachable");
63
+ if (gatewayIpc.richOverride) {
64
+ return gatewayIpc.richOverride(params?.contactId as string);
65
+ }
66
+ return richContactForId(params?.contactId as string);
67
+ }
55
68
  if (method === "record_invite_redemption") {
56
69
  if (gatewayIpc.claimThrows) throw new Error("gateway unreachable");
57
70
  onGatewayClaim?.();
@@ -81,6 +94,76 @@ mock.module("../ipc/gateway-client.js", () => ({
81
94
  },
82
95
  }));
83
96
 
97
+ // Serves contacts_get_rich (the gateway ACL read backing the gate-status
98
+ // fallback) from the seeded local contact, so gate resolution sources status
99
+ // from the gateway path rather than the local channel column.
100
+ function richContactForId(contactId: string | undefined) {
101
+ if (!contactId) return undefined;
102
+ const contact = getContact(contactId);
103
+ if (!contact) return undefined;
104
+ // ACL columns live on the still-present DB rows, not the slimmed interfaces;
105
+ // read them raw to build the gateway-rich response the production read parses.
106
+ const contactRole = (
107
+ getSqlite()
108
+ .query("SELECT role FROM contacts WHERE id = ?")
109
+ .get(contact.id) as { role: string } | undefined
110
+ )?.role;
111
+ return {
112
+ ok: true,
113
+ contact: {
114
+ id: contact.id,
115
+ displayName: contact.displayName,
116
+ role: contactRole ?? "contact",
117
+ interactionCount: contact.interactionCount,
118
+ createdAt: contact.createdAt,
119
+ updatedAt: contact.updatedAt,
120
+ channels: contact.channels.map((c) => {
121
+ const acl = rawChannelAcl(c.id);
122
+ return {
123
+ id: c.id,
124
+ contactId: c.contactId,
125
+ type: c.type,
126
+ address: c.address,
127
+ isPrimary: c.isPrimary,
128
+ externalUserId: c.externalChatId,
129
+ status: acl.status,
130
+ policy: acl.policy,
131
+ verifiedAt: acl.verifiedAt,
132
+ verifiedVia: acl.verifiedVia,
133
+ lastSeenAt: acl.lastSeenAt,
134
+ interactionCount: acl.interactionCount,
135
+ lastInteraction: acl.lastInteraction,
136
+ revokedReason: acl.revokedReason,
137
+ blockedReason: acl.blockedReason,
138
+ };
139
+ }),
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Read a channel's ACL columns straight off the still-present DB row. */
145
+ function rawChannelAcl(channelId: string) {
146
+ return getSqlite()
147
+ .query(
148
+ `SELECT status, policy, verified_at AS verifiedAt, verified_via AS verifiedVia,
149
+ last_seen_at AS lastSeenAt, interaction_count AS interactionCount,
150
+ last_interaction AS lastInteraction, revoked_reason AS revokedReason,
151
+ blocked_reason AS blockedReason
152
+ FROM contact_channels WHERE id = ?`,
153
+ )
154
+ .get(channelId) as {
155
+ status: string;
156
+ policy: string;
157
+ verifiedAt: number | null;
158
+ verifiedVia: string | null;
159
+ lastSeenAt: number | null;
160
+ interactionCount: number;
161
+ lastInteraction: number | null;
162
+ revokedReason: string | null;
163
+ blockedReason: string | null;
164
+ };
165
+ }
166
+
84
167
  // Lets a test inject a side-effect into the gateway claim — runs after the
85
168
  // service's pre-validation but before the assistant use-bump, so it can race a
86
169
  // revoke into the window that makes `recordInviteUse` return false.
@@ -89,6 +172,8 @@ let onGatewayClaim: (() => void) | null = null;
89
172
  function resetGatewayIpc() {
90
173
  gatewayIpc.claim = { ok: true, updated: true, mirrored: true };
91
174
  gatewayIpc.claimThrows = false;
175
+ gatewayIpc.richThrows = false;
176
+ gatewayIpc.richOverride = null;
92
177
  gatewayIpc.activationVerified = true;
93
178
  gatewayIpc.activationChannelId = "gw-channel-id";
94
179
  gatewayIpc.calls = [];
@@ -102,7 +187,6 @@ import {
102
187
  getContact,
103
188
  upsertContact,
104
189
  } from "../contacts/contact-store.js";
105
- import { upsertContactChannel } from "../contacts/contacts-write.js";
106
190
  import { getSqlite } from "../memory/db-connection.js";
107
191
  import { initializeDb } from "../memory/db-init.js";
108
192
  import {
@@ -116,6 +200,7 @@ import {
116
200
  resolveMemberGateStatus,
117
201
  } from "../runtime/invite-redemption-service.js";
118
202
  import { hashVoiceCode } from "../util/voice-code.js";
203
+ import { seedContactChannel } from "./helpers/seed-contact-channel.js";
119
204
 
120
205
  await initializeDb();
121
206
 
@@ -130,6 +215,15 @@ function createTargetContact(displayName = "Target Contact"): string {
130
215
  return upsertContact({ displayName, role: "contact" }).id;
131
216
  }
132
217
 
218
+ /** Read a contact's local role column (dropped from the Contact interface). */
219
+ function localContactRole(contactId: string): string | undefined {
220
+ return (
221
+ getSqlite()
222
+ .query("SELECT role FROM contacts WHERE id = ?")
223
+ .get(contactId) as { role: string } | undefined
224
+ )?.role;
225
+ }
226
+
133
227
  describe("invite-redemption-service", () => {
134
228
  beforeEach(() => {
135
229
  resetTables();
@@ -181,15 +275,16 @@ describe("invite-redemption-service", () => {
181
275
 
182
276
  expect(outcome.ok).toBe(true);
183
277
 
278
+ // The gateway owns the verified ACL verdict (relayed via
279
+ // upsert_verified_channel); the local mirror persists identity only.
184
280
  const result = findContactChannel({
185
281
  channelType: "telegram",
186
282
  address: "user-1",
187
283
  });
188
-
189
284
  expect(result).not.toBeNull();
190
- expect(result!.channel.verifiedAt).toBeGreaterThan(0);
191
- expect(result!.channel.verifiedVia).toBe("invite");
192
- expect(result!.channel.status).toBe("active");
285
+ expect(
286
+ gatewayIpc.calls.some((c) => c.method === "upsert_verified_channel"),
287
+ ).toBe(true);
193
288
  });
194
289
 
195
290
  test("marks channel as verified via invite on 6-digit code redemption", async () => {
@@ -210,15 +305,15 @@ describe("invite-redemption-service", () => {
210
305
 
211
306
  expect(outcome.ok).toBe(true);
212
307
 
308
+ // The gateway owns the verified ACL verdict; the local mirror is identity-only.
213
309
  const result = findContactChannel({
214
310
  channelType: "telegram",
215
311
  address: "code-user-1",
216
312
  });
217
-
218
313
  expect(result).not.toBeNull();
219
- expect(result!.channel.verifiedAt).toBeGreaterThan(0);
220
- expect(result!.channel.verifiedVia).toBe("invite");
221
- expect(result!.channel.status).toBe("active");
314
+ expect(
315
+ gatewayIpc.calls.some((c) => c.method === "upsert_verified_channel"),
316
+ ).toBe(true);
222
317
  });
223
318
 
224
319
  test("returns invalid_token for a bogus token", async () => {
@@ -329,7 +424,7 @@ describe("invite-redemption-service", () => {
329
424
 
330
425
  test("returns already_member when user is already an active member", async () => {
331
426
  // Pre-create an active member and find their contact
332
- const member = upsertContactChannel({
427
+ const member = seedContactChannel({
333
428
  sourceChannel: "telegram",
334
429
  externalUserId: "existing-user",
335
430
  status: "active",
@@ -338,7 +433,7 @@ describe("invite-redemption-service", () => {
338
433
  // Create an invite targeting the same contact that owns the channel
339
434
  const { rawToken } = createInvite({
340
435
  sourceChannel: "telegram",
341
- contactId: member!.contact.id,
436
+ contactId: member.contactId,
342
437
  maxUses: 5,
343
438
  });
344
439
 
@@ -361,7 +456,7 @@ describe("invite-redemption-service", () => {
361
456
 
362
457
  test("returns invalid_token for a blocked member to avoid leaking membership status", async () => {
363
458
  // Pre-create a blocked member and find their contact
364
- const member = upsertContactChannel({
459
+ const member = seedContactChannel({
365
460
  sourceChannel: "telegram",
366
461
  externalUserId: "blocked-user",
367
462
  status: "blocked",
@@ -370,7 +465,7 @@ describe("invite-redemption-service", () => {
370
465
  // Create an invite targeting the same contact that owns the channel
371
466
  const { rawToken } = createInvite({
372
467
  sourceChannel: "telegram",
373
- contactId: member!.contact.id,
468
+ contactId: member.contactId,
374
469
  maxUses: 5,
375
470
  });
376
471
 
@@ -383,18 +478,150 @@ describe("invite-redemption-service", () => {
383
478
  expect(outcome).toEqual({ ok: false, reason: "invalid_token" });
384
479
  });
385
480
 
481
+ test("matches an active member by (type,address) when the gateway row has a divergent uuid", async () => {
482
+ const member = seedContactChannel({
483
+ sourceChannel: "telegram",
484
+ externalUserId: "divergent-user",
485
+ status: "active",
486
+ });
487
+
488
+ // The gateway row for the same (type,address) carries a DIFFERENT id, as a
489
+ // reconcile divergence would produce. Matching by id alone would miss it.
490
+ gatewayIpc.richOverride = () => ({
491
+ ok: true,
492
+ contact: {
493
+ id: member.contactId,
494
+ displayName: "divergent-user",
495
+ role: "contact",
496
+ interactionCount: 0,
497
+ createdAt: 1,
498
+ updatedAt: 1,
499
+ channels: [
500
+ {
501
+ id: "gateway-divergent-uuid",
502
+ contactId: member.contactId,
503
+ type: "telegram",
504
+ address: "divergent-user",
505
+ isPrimary: false,
506
+ externalUserId: null,
507
+ status: "active",
508
+ policy: "allow",
509
+ verifiedAt: 1,
510
+ verifiedVia: "invite",
511
+ lastSeenAt: null,
512
+ interactionCount: 0,
513
+ lastInteraction: null,
514
+ revokedReason: null,
515
+ blockedReason: null,
516
+ },
517
+ ],
518
+ },
519
+ });
520
+
521
+ const { rawToken } = createInvite({
522
+ sourceChannel: "telegram",
523
+ contactId: member.contactId,
524
+ maxUses: 5,
525
+ });
526
+
527
+ const outcome = await redeemInvite({
528
+ rawToken,
529
+ sourceChannel: "telegram",
530
+ externalUserId: "divergent-user",
531
+ });
532
+
533
+ expect(outcome.ok).toBe(true);
534
+ expect((outcome as { type: string }).type).toBe("already_member");
535
+ });
536
+
537
+ test("blocks via the (type,address) match when the gateway row has a divergent uuid", async () => {
538
+ const member = seedContactChannel({
539
+ sourceChannel: "telegram",
540
+ externalUserId: "divergent-blocked",
541
+ status: "blocked",
542
+ });
543
+
544
+ gatewayIpc.richOverride = () => ({
545
+ ok: true,
546
+ contact: {
547
+ id: member.contactId,
548
+ displayName: "divergent-blocked",
549
+ role: "contact",
550
+ interactionCount: 0,
551
+ createdAt: 1,
552
+ updatedAt: 1,
553
+ channels: [
554
+ {
555
+ id: "gateway-divergent-blocked-uuid",
556
+ contactId: member.contactId,
557
+ type: "telegram",
558
+ // Case-divergent address must still match (COLLATE NOCASE).
559
+ address: "DIVERGENT-BLOCKED",
560
+ isPrimary: false,
561
+ externalUserId: null,
562
+ status: "blocked",
563
+ policy: "deny",
564
+ verifiedAt: null,
565
+ verifiedVia: null,
566
+ lastSeenAt: null,
567
+ interactionCount: 0,
568
+ lastInteraction: null,
569
+ revokedReason: null,
570
+ blockedReason: "guardian blocked",
571
+ },
572
+ ],
573
+ },
574
+ });
575
+
576
+ const { rawToken } = createInvite({
577
+ sourceChannel: "telegram",
578
+ contactId: member.contactId,
579
+ maxUses: 5,
580
+ });
581
+
582
+ const outcome = await redeemInvite({
583
+ rawToken,
584
+ sourceChannel: "telegram",
585
+ externalUserId: "divergent-blocked",
586
+ });
587
+
588
+ expect(outcome).toEqual({ ok: false, reason: "invalid_token" });
589
+ });
590
+
591
+ test("fails open (no throw) when the gateway gate-status read is unreachable", async () => {
592
+ // No verdict member and an unreachable gateway read must degrade to the
593
+ // fail-open path: redemption still resolves rather than throwing.
594
+ const member = seedContactChannel({
595
+ sourceChannel: "telegram",
596
+ externalUserId: "readfail-user",
597
+ status: "revoked",
598
+ });
599
+
600
+ gatewayIpc.richThrows = true;
601
+
602
+ const { rawToken } = createInvite({
603
+ sourceChannel: "telegram",
604
+ contactId: member.contactId,
605
+ maxUses: 1,
606
+ });
607
+
608
+ const outcome = await redeemInvite({
609
+ rawToken,
610
+ sourceChannel: "telegram",
611
+ externalUserId: "readfail-user",
612
+ });
613
+
614
+ expect(outcome.ok).toBe(true);
615
+ });
616
+
386
617
  test("binds redeemer to the invite's target contact, not the guardian", async () => {
387
618
  // Pre-create a guardian contact with a revoked telegram channel
388
- const guardianContact = upsertContact({
619
+ const guardianSeed = seedContactChannel({
620
+ sourceChannel: "telegram",
621
+ externalUserId: "guardian-tg-id",
389
622
  displayName: "Guardian",
390
623
  role: "guardian",
391
- channels: [
392
- {
393
- type: "telegram",
394
- address: "guardian-tg-id",
395
- status: "revoked",
396
- },
397
- ],
624
+ status: "revoked",
398
625
  });
399
626
 
400
627
  // Create a separate target contact "Mom"
@@ -440,32 +667,27 @@ describe("invite-redemption-service", () => {
440
667
  });
441
668
  expect(result).not.toBeNull();
442
669
  expect(result!.contact.id).toBe(momContact.id);
443
- expect(result!.channel.status).toBe("active");
444
670
 
445
671
  // Verify the original guardian contact was NOT modified
446
- const guardian = getContact(guardianContact.id);
672
+ const guardian = getContact(guardianSeed.contactId);
447
673
  expect(guardian).not.toBeNull();
448
- expect(guardian!.role).toBe("guardian");
674
+ expect(localContactRole(guardianSeed.contactId)).toBe("guardian");
449
675
  });
450
676
 
451
677
  test("downgrades guardian to contact when redeeming invite targeting own contact", async () => {
452
678
  // Create a guardian contact with a revoked channel
453
- const guardianContact = upsertContact({
679
+ const guardianSeed = seedContactChannel({
680
+ sourceChannel: "telegram",
681
+ externalUserId: "guardian-own-id",
454
682
  displayName: "Guardian",
455
683
  role: "guardian",
456
- channels: [
457
- {
458
- type: "telegram",
459
- address: "guardian-own-id",
460
- status: "revoked",
461
- },
462
- ],
684
+ status: "revoked",
463
685
  });
464
686
 
465
687
  // Create invite targeting the guardian's own contact
466
688
  const { rawToken } = createInvite({
467
689
  sourceChannel: "telegram",
468
- contactId: guardianContact.id,
690
+ contactId: guardianSeed.contactId,
469
691
  maxUses: 5,
470
692
  });
471
693
 
@@ -477,23 +699,23 @@ describe("invite-redemption-service", () => {
477
699
 
478
700
  expect(outcome.ok).toBe(true);
479
701
 
480
- // The guardian should now be downgraded to "contact"
481
- const updated = getContact(guardianContact.id);
482
- expect(updated!.role).toBe("contact");
702
+ // The role downgrade is gateway-owned; redemption relays the activation for
703
+ // the guardian's own contact rather than mutating the local role.
704
+ expect(
705
+ gatewayIpc.calls.some((c) => c.method === "upsert_verified_channel"),
706
+ ).toBe(true);
707
+ const updated = getContact(guardianSeed.contactId);
708
+ expect(updated).not.toBeNull();
483
709
  });
484
710
 
485
711
  test("binds redeemer to the invite's target contact via 6-digit code, not the guardian", async () => {
486
712
  // Pre-create a guardian contact with a revoked telegram channel
487
- const guardianContact = upsertContact({
713
+ const guardianSeed = seedContactChannel({
714
+ sourceChannel: "telegram",
715
+ externalUserId: "guardian-code-id",
488
716
  displayName: "Guardian",
489
717
  role: "guardian",
490
- channels: [
491
- {
492
- type: "telegram",
493
- address: "guardian-code-id",
494
- status: "revoked",
495
- },
496
- ],
718
+ status: "revoked",
497
719
  });
498
720
 
499
721
  // Create a separate target contact "Mom"
@@ -530,12 +752,11 @@ describe("invite-redemption-service", () => {
530
752
  });
531
753
  expect(result).not.toBeNull();
532
754
  expect(result!.contact.id).toBe(momContact.id);
533
- expect(result!.channel.status).toBe("active");
534
755
 
535
756
  // Verify the original guardian contact was NOT modified
536
- const guardian = getContact(guardianContact.id);
757
+ const guardian = getContact(guardianSeed.contactId);
537
758
  expect(guardian).not.toBeNull();
538
- expect(guardian!.role).toBe("guardian");
759
+ expect(localContactRole(guardianSeed.contactId)).toBe("guardian");
539
760
  });
540
761
 
541
762
  test("does not return already_member for a revoked member", async () => {
@@ -547,12 +768,11 @@ describe("invite-redemption-service", () => {
547
768
  });
548
769
 
549
770
  // Pre-create a revoked member
550
- const member = upsertContactChannel({
771
+ seedContactChannel({
551
772
  sourceChannel: "telegram",
552
773
  externalUserId: "revoked-user",
553
774
  status: "revoked",
554
775
  });
555
- expect(member!.channel.status).toBe("revoked");
556
776
 
557
777
  const outcome = await redeemInvite({
558
778
  rawToken,
@@ -605,7 +825,7 @@ describe("invite-redemption-service", () => {
605
825
 
606
826
  test("returns invalid_token for an active member with a bogus token (no membership probing)", async () => {
607
827
  // Pre-create an active member
608
- upsertContactChannel({
828
+ seedContactChannel({
609
829
  sourceChannel: "telegram",
610
830
  externalUserId: "probed-user",
611
831
  status: "active",
@@ -632,7 +852,7 @@ describe("invite-redemption-service", () => {
632
852
  });
633
853
 
634
854
  // Pre-create an active member
635
- upsertContactChannel({
855
+ seedContactChannel({
636
856
  sourceChannel: "telegram",
637
857
  externalUserId: "expired-token-user",
638
858
  status: "active",
@@ -658,7 +878,7 @@ describe("invite-redemption-service", () => {
658
878
  });
659
879
 
660
880
  // Pre-create an active member on telegram
661
- upsertContactChannel({
881
+ seedContactChannel({
662
882
  sourceChannel: "telegram",
663
883
  externalUserId: "cross-channel-user",
664
884
  status: "active",
@@ -821,7 +1041,7 @@ describe("invite-redemption-service", () => {
821
1041
  });
822
1042
 
823
1043
  // Seed an already-active member bound to the invite's target contact.
824
- upsertContactChannel({
1044
+ seedContactChannel({
825
1045
  sourceChannel: "telegram",
826
1046
  externalUserId: "already-member-user",
827
1047
  role: "contact",
@@ -34,6 +34,40 @@ mock.module("../calls/call-domain.js", () => ({
34
34
  startInviteCall: async () => mockStartInviteCallResult,
35
35
  }));
36
36
 
37
+ // Model the gateway: the redemption claim (record_invite_redemption) and the
38
+ // gateway-owned activation (upsert_verified_channel) are both relayed. The
39
+ // activation write fails closed in production, so the mock must serve a
40
+ // verified upsert for the legitimate-success redemption paths.
41
+ const gatewayIpc = {
42
+ claim: { ok: true, updated: true, mirrored: true },
43
+ activationVerified: true,
44
+ };
45
+ mock.module("../ipc/gateway-client.js", () => ({
46
+ ipcCallPersistent: async (
47
+ method: string,
48
+ params?: Record<string, unknown>,
49
+ ) => {
50
+ if (method === "record_invite_redemption") return gatewayIpc.claim;
51
+ if (method === "upsert_verified_channel") {
52
+ if (!gatewayIpc.activationVerified) return { ok: true, verified: false };
53
+ return {
54
+ ok: true,
55
+ verified: true,
56
+ channel: {
57
+ id: "gw-channel-id",
58
+ contactId: (params?.contactId as string) ?? "gw-contact",
59
+ type: (params?.type as string) ?? "telegram",
60
+ address: (params?.address as string) ?? "gw-addr",
61
+ status: "active",
62
+ verifiedAt: 1,
63
+ verifiedVia: (params?.verifiedVia as string) ?? "invite",
64
+ },
65
+ };
66
+ }
67
+ return undefined;
68
+ },
69
+ }));
70
+
37
71
  import { upsertContact } from "../contacts/contact-store.js";
38
72
  import { getSqlite } from "../memory/db-connection.js";
39
73
  import { initializeDb } from "../memory/db-init.js";
@@ -19,15 +19,78 @@ mock.module("../security/secure-keys.js", () => ({
19
19
  // mutating. Default the claim to consumed (updated:true) so these assistant-side
20
20
  // handler tests exercise the happy redemption path.
21
21
  mock.module("../ipc/gateway-client.js", () => ({
22
- ipcCallPersistent: async (method: string) => {
22
+ ipcCallPersistent: async (
23
+ method: string,
24
+ params?: Record<string, unknown>,
25
+ ) => {
26
+ if (method === "contacts_get_rich") {
27
+ return richContactForId(params?.contactId as string);
28
+ }
23
29
  if (method === "record_invite_redemption") {
24
30
  return { ok: true, updated: true, mirrored: true };
25
31
  }
32
+ if (method === "upsert_verified_channel") {
33
+ // Gateway-as-SoT activation: return a verified channel so the gateway-first
34
+ // relay lands its write before mirroring identity locally.
35
+ return {
36
+ ok: true,
37
+ verified: true,
38
+ channel: {
39
+ id: "gw-channel-1",
40
+ contactId: (params?.contactId as string) ?? "gw-contact-1",
41
+ type: (params?.type as string) ?? "telegram",
42
+ address: (params?.address as string) ?? "",
43
+ status: "active",
44
+ verifiedAt: Date.now(),
45
+ verifiedVia: (params?.verifiedVia as string) ?? "invite",
46
+ },
47
+ };
48
+ }
26
49
  return undefined;
27
50
  },
28
51
  }));
29
52
 
30
- import { upsertContact } from "../contacts/contact-store.js";
53
+ // Serves contacts_get_rich (the gateway ACL read backing the gate-status
54
+ // fallback) from the seeded local contact identity. Channel ACL state is
55
+ // gateway-owned, so a contact with a mirrored channel reports "active" here —
56
+ // the local status column is drained and never consulted.
57
+ function richContactForId(contactId: string | undefined) {
58
+ if (!contactId) return undefined;
59
+ const contact = getContact(contactId);
60
+ if (!contact) return undefined;
61
+ // ACL columns are gateway-owned; the projection reports "active" and no longer
62
+ // mirrors the drained local ACL fields off the typed contact/channel.
63
+ return {
64
+ ok: true,
65
+ contact: {
66
+ id: contact.id,
67
+ displayName: contact.displayName,
68
+ role: "contact",
69
+ interactionCount: contact.interactionCount,
70
+ createdAt: contact.createdAt,
71
+ updatedAt: contact.updatedAt,
72
+ channels: contact.channels.map((c) => ({
73
+ id: c.id,
74
+ contactId: c.contactId,
75
+ type: c.type,
76
+ address: c.address,
77
+ isPrimary: c.isPrimary,
78
+ externalUserId: c.externalChatId,
79
+ status: "active",
80
+ policy: "allow",
81
+ verifiedAt: null,
82
+ verifiedVia: null,
83
+ lastSeenAt: null,
84
+ interactionCount: 0,
85
+ lastInteraction: null,
86
+ revokedReason: null,
87
+ blockedReason: null,
88
+ })),
89
+ },
90
+ };
91
+ }
92
+
93
+ import { getContact, upsertContact } from "../contacts/contact-store.js";
31
94
  import { handleMintInvite } from "../ipc/routes/invite-ipc-routes.js";
32
95
  import { getSqlite } from "../memory/db-connection.js";
33
96
  import { initializeDb } from "../memory/db-init.js";