@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
@@ -6,50 +6,33 @@
6
6
  * identity and access-control state.
7
7
  */
8
8
 
9
- import { emitContactChange } from "./contact-events.js";
10
9
  import {
11
10
  findContactChannel,
12
- findGuardianForChannel,
13
11
  getChannelById,
14
12
  getContact,
15
13
  getContactInternal,
16
- updateChannelStatus,
17
14
  upsertContact,
18
15
  } from "./contact-store.js";
19
- import type {
20
- ChannelPolicy,
21
- ChannelStatus,
22
- ContactRole,
23
- ContactWriteResult,
24
- } from "./types.js";
16
+ import type { ContactWriteResult } from "./types.js";
25
17
 
26
18
  // ── Guardian operations ──────────────────────────────────────────────
27
19
 
28
20
  /**
29
- * Revoke a guardian binding by updating the contacts table.
30
- * Returns true when a guardian channel was found and revoked, false otherwise.
21
+ * No-op shim: the guardian channel ACL revoke is gateway-owned (relayed via
22
+ * mark_channel_revoked). Retained while callers still invoke it; the return is
23
+ * discarded.
31
24
  */
32
- export function revokeGuardianBinding(channel: string): boolean {
33
- // Local-store read, not the gateway: this read selects the row that the
34
- // updateChannelStatus write below mutates, so it must stay transactionally
35
- // consistent with that write. Leave for Combo 11 / gateway-bootstrap-binding.
36
- const guardian = findGuardianForChannel(channel);
37
- if (!guardian) return false;
38
-
39
- updateChannelStatus(guardian.channel.id, {
40
- status: "revoked",
41
- revokedReason: "binding_revoked",
42
- });
43
- emitContactChange();
44
- return true;
25
+ export function revokeGuardianBinding(_channel: string): boolean {
26
+ return false;
45
27
  }
46
28
 
47
29
  // ── Member operations ────────────────────────────────────────────────
48
30
 
49
31
  /**
50
- * Upsert a contact and channel by writing to the contacts table.
51
- * Returns the native Contact + ContactChannel, or null if no usable
52
- * identity was provided or the lookup failed after upsert.
32
+ * Upsert a contact and channel identity by writing to the contacts table.
33
+ * Persists only identity/INFO the gateway owns the ACL verdict. Returns the
34
+ * native Contact + ContactChannel, or null if no usable identity was provided
35
+ * or the lookup failed after upsert.
53
36
  */
54
37
  export function upsertContactChannel(params: {
55
38
  sourceChannel: string;
@@ -57,12 +40,7 @@ export function upsertContactChannel(params: {
57
40
  externalChatId?: string;
58
41
  displayName?: string;
59
42
  username?: string;
60
- policy?: string;
61
- status?: string;
62
43
  inviteId?: string;
63
- verifiedAt?: number;
64
- verifiedVia?: string;
65
- role?: ContactRole;
66
44
  contactId?: string;
67
45
  }): ContactWriteResult | null {
68
46
  let address: string;
@@ -91,19 +69,12 @@ export function upsertContactChannel(params: {
91
69
  upsertContact({
92
70
  id: params.contactId,
93
71
  displayName,
94
- role: params.role,
95
72
  channels: [
96
73
  {
97
74
  type: params.sourceChannel,
98
75
  address,
99
76
  externalChatId: params.externalChatId ?? null,
100
- status: (params.status as ChannelStatus) ?? undefined,
101
- policy: (params.policy as ChannelPolicy) ?? undefined,
102
77
  inviteId: params.inviteId ?? null,
103
- revokedReason: params.status === "active" ? null : undefined,
104
- blockedReason: params.status === "active" ? null : undefined,
105
- verifiedAt: params.verifiedAt ?? undefined,
106
- verifiedVia: params.verifiedVia ?? undefined,
107
78
  },
108
79
  ],
109
80
  // When a specific contactId is provided, reassign conflicting channels from
@@ -132,32 +103,21 @@ export function upsertContactChannel(params: {
132
103
  }
133
104
 
134
105
  /**
135
- * Revoke a contact channel by updating its status.
136
- * The memberId may be a plain channel ID (internal callers) or a composite
137
- * contactId:channelId (from the API response format).
106
+ * Resolve the native contact/channel for a member id. The ACL downgrade is
107
+ * gateway-owned (relayed via mark_channel_revoked); this no longer mutates
108
+ * local status. The memberId may be a plain channel ID (internal callers) or a
109
+ * composite contactId:channelId (from the API response format).
138
110
  */
139
- export function revokeMember(
140
- memberId: string,
141
- reason?: string,
142
- ): ContactWriteResult | null {
111
+ export function revokeMember(memberId: string): ContactWriteResult | null {
143
112
  const channelId = memberId.includes(":") ? memberId.split(":")[1] : memberId;
144
113
 
145
114
  const channelRow = getChannelById(channelId);
146
115
  if (!channelRow) return null;
147
- if (channelRow.status !== "active" && channelRow.status !== "pending")
148
- return null;
149
-
150
- updateChannelStatus(channelId, {
151
- status: "revoked",
152
- revokedReason: reason ?? null,
153
- });
154
116
 
155
- // Use unscoped lookup — the contact was already resolved via channel ID
156
117
  const contact = getContactInternal(channelRow.contactId);
157
118
  if (!contact) return null;
158
- const updatedChannel = contact.channels.find((ch) => ch.id === channelId);
159
- if (!updatedChannel) return null;
119
+ const channel = contact.channels.find((ch) => ch.id === channelId);
120
+ if (!channel) return null;
160
121
 
161
- emitContactChange();
162
- return { contact, channel: updatedChannel };
122
+ return { contact, channel };
163
123
  }
@@ -0,0 +1,51 @@
1
+ import { GetContactIpcResponseSchema } from "@vellumai/gateway-client/gateway-ipc-contracts";
2
+
3
+ import { ipcCallPersistent } from "../ipc/gateway-client.js";
4
+ import { getLogger } from "../util/logger.js";
5
+ import type { ContactChannel } from "./types.js";
6
+
7
+ const log = getLogger("gateway-channel-read");
8
+
9
+ /**
10
+ * Read a contact channel's verified state from the gateway contact-channel read
11
+ * (ACL source of truth). Covers all contacts, not just guardian deliveries.
12
+ *
13
+ * Matches the gateway row by logical identity — `(type, address)`,
14
+ * case-insensitive — not by id, so a reconcile-divergent row that the gateway
15
+ * write helpers re-keyed under a different UUID is still found.
16
+ *
17
+ * Returns `undefined` for unreachable reads (gateway down, IPC timeout, schema
18
+ * mismatch) or when no such channel exists, so callers fail open.
19
+ */
20
+ export async function gatewayContactChannelState(
21
+ channel: Pick<ContactChannel, "contactId" | "type" | "address">,
22
+ ): Promise<{ status: string; verifiedAt: number | null } | undefined> {
23
+ let result: unknown;
24
+ try {
25
+ result = await ipcCallPersistent("contacts_get_rich", {
26
+ contactId: channel.contactId,
27
+ });
28
+ } catch (err) {
29
+ log.warn(
30
+ { err, contactId: channel.contactId },
31
+ "contacts_get_rich unreachable — failing open",
32
+ );
33
+ return undefined;
34
+ }
35
+ if (!result || (result as { contact?: unknown }).contact == null) {
36
+ return undefined;
37
+ }
38
+ const parsed = GetContactIpcResponseSchema.safeParse(result);
39
+ if (!parsed.success) {
40
+ log.warn(
41
+ { err: parsed.error, contactId: channel.contactId },
42
+ "contacts_get_rich response failed schema parse — failing open",
43
+ );
44
+ return undefined;
45
+ }
46
+ const address = channel.address.toLowerCase();
47
+ const ch = parsed.data.contact.channels.find(
48
+ (c) => c.type === channel.type && c.address.toLowerCase() === address,
49
+ );
50
+ return ch ? { status: ch.status, verifiedAt: ch.verifiedAt } : undefined;
51
+ }
@@ -47,13 +47,14 @@ export type ActivateMemberOutcome =
47
47
  * assistant DB best-effort. The gateway owns the ACL outcome; the local mirror
48
48
  * supplies the native contact/channel callers still wire downstream.
49
49
  *
50
- * A gateway failure fails open with a logged warning (matching the redemption
51
- * service's record_invite_redemption posture) so a transient gateway outage
52
- * never drops a legitimate activation the local mirror still stands.
50
+ * A gateway write failure fails closed: the mirror is identity-only, so a
51
+ * gateway that did not persist the activation must surface as `refused` rather
52
+ * than reporting success off a local row the gateway never verified.
53
53
  *
54
54
  * Returns an `activated` outcome with a stable memberId on success, or
55
- * `refused` when the gateway denies the actor. A best-effort local-mirror
56
- * failure never downgrades a verified gateway activation.
55
+ * `refused` when the gateway denies the actor or the gateway write fails. A
56
+ * best-effort local-mirror failure never downgrades a verified gateway
57
+ * activation.
57
58
  */
58
59
  export async function activateMemberChannel(
59
60
  params: ActivateMemberChannelParams,
@@ -89,12 +90,14 @@ export async function activateMemberChannel(
89
90
  }
90
91
  gatewayChannelId = parsed.channel?.id;
91
92
  } catch (err) {
92
- // Fail-open: the gateway write may or may not have landed. Proceed to the
93
- // local mirror so a transient outage never drops a legitimate activation.
93
+ // Fail-closed: the gateway owns the ACL verdict and the local mirror is
94
+ // identity-only. If the gateway write did not land, the activation is not
95
+ // persisted to the source of truth, so we must not report success.
94
96
  log.warn(
95
97
  { err, sourceChannel: params.sourceChannel },
96
- "upsert_verified_channel relay unavailablefailing open (local mirror still applies)",
98
+ "upsert_verified_channel relay failedrefusing activation (gateway write did not land)",
97
99
  );
100
+ return { status: "refused" };
98
101
  }
99
102
  }
100
103
 
@@ -113,7 +116,10 @@ export async function activateMemberChannel(
113
116
  return { status: "activated", memberId, member };
114
117
  }
115
118
 
116
- /** Best-effort local mirror of the activation. Swallows failures. */
119
+ /**
120
+ * Best-effort local mirror of the activation. Swallows failures. Persists only
121
+ * the native contact/channel identity row — the gateway owns the ACL verdict.
122
+ */
117
123
  function mirrorLocalActivation(
118
124
  params: ActivateMemberChannelParams,
119
125
  ): ContactWriteResult | null {
@@ -124,12 +130,7 @@ function mirrorLocalActivation(
124
130
  externalChatId: params.externalChatId,
125
131
  displayName: params.displayName,
126
132
  username: params.username,
127
- role: "contact",
128
- status: "active",
129
- policy: params.policy ?? "allow",
130
133
  inviteId: params.inviteId,
131
- verifiedAt: params.verifiedAt,
132
- verifiedVia: params.verifiedVia ?? "invite",
133
134
  contactId: params.contactId,
134
135
  });
135
136
  } catch (err) {
@@ -144,12 +145,13 @@ function mirrorLocalActivation(
144
145
  // ── Revoke ───────────────────────────────────────────────────────────
145
146
 
146
147
  /**
147
- * Revoke a member channel gateway-first, then mirror the downgrade to the
148
- * assistant DB best-effort. The memberId may be a plain channel ID or the
149
- * composite contactId:channelId form revokeMember accepts.
148
+ * Revoke a member channel gateway-first. The gateway owns the ACL outcome; the
149
+ * memberId may be a plain channel ID or the composite contactId:channelId form
150
+ * revokeMember accepts.
150
151
  *
151
- * Returns the local ContactWriteResult so callers still get the native
152
- * contact/channel, or null when the local mirror produces no result.
152
+ * Returns the locally-resolved native contact/channel for the revoked id, or
153
+ * null when no local row exists. The local read is best-effort and never gates
154
+ * the gateway-owned downgrade.
153
155
  */
154
156
  export async function revokeMemberChannel(
155
157
  memberId: string,
@@ -158,8 +160,8 @@ export async function revokeMemberChannel(
158
160
  const channelId = memberId.includes(":") ? memberId.split(":")[1] : memberId;
159
161
 
160
162
  // Always relay; the gateway owns the ACL outcome and mark_channel_revoked is
161
- // idempotent (already-revoked → didWrite:false). Skipping on the local mirror
162
- // status would suppress a needed revoke when the mirror lags the gateway.
163
+ // idempotent (already-revoked → didWrite:false). Skipping on the local row
164
+ // status would suppress a needed revoke when the local read lags the gateway.
163
165
  const result = await ipcCallPersistent("mark_channel_revoked", {
164
166
  contactChannelId: channelId,
165
167
  reason,
@@ -169,20 +171,12 @@ export async function revokeMemberChannel(
169
171
  throw new Error("mark_channel_revoked relay returned ok: false");
170
172
  }
171
173
 
172
- return mirrorLocalRevoke(memberId, reason);
173
- }
174
-
175
- /** Best-effort local mirror of the revoke. Swallows failures. */
176
- function mirrorLocalRevoke(
177
- memberId: string,
178
- reason?: string,
179
- ): ContactWriteResult | null {
180
174
  try {
181
- return revokeMember(memberId, reason);
175
+ return revokeMember(memberId);
182
176
  } catch (err) {
183
177
  log.error(
184
178
  { err, memberId },
185
- "Local revoke mirror failed after gateway revoke; gateway downgrade stands",
179
+ "Local revoke read failed after gateway revoke; gateway downgrade stands",
186
180
  );
187
181
  return null;
188
182
  }
@@ -30,20 +30,12 @@ export interface Contact {
30
30
  displayName: string;
31
31
  /** Free-text notes about this contact (e.g. relationship, communication preferences). */
32
32
  notes: string | null;
33
+ role: ContactRole;
33
34
  lastInteraction: number | null;
34
35
  interactionCount: number;
35
36
  createdAt: number;
36
37
  updatedAt: number;
37
- role: ContactRole;
38
38
  contactType: ContactType;
39
- /**
40
- * Internal auth identity (e.g. "vellum-principal-<uuid>"). Only meaningful
41
- * for guardian contacts — it ties the contact record to the auth layer so
42
- * the system can verify "this API caller IS this guardian" via JWT
43
- * actorPrincipalId. Always null for non-guardian contacts, which are
44
- * identified by channel address instead.
45
- */
46
- principalId: string | null;
47
39
  /** Workspace-relative path to a per-user persona file for this contact. */
48
40
  userFile: string | null;
49
41
  }
@@ -63,13 +55,9 @@ export interface ContactChannel {
63
55
  address: string;
64
56
  isPrimary: boolean;
65
57
  externalChatId: string | null;
66
- status: ChannelStatus;
67
- policy: ChannelPolicy;
68
- verifiedAt: number | null;
69
- verifiedVia: string | null;
70
58
  inviteId: string | null;
71
- revokedReason: string | null;
72
- blockedReason: string | null;
59
+ // INFO telemetry (not ACL): interaction stats written locally by the gateway's
60
+ // handle-inbound mirror. Model-facing turn context reads these.
73
61
  lastSeenAt: number | null;
74
62
  interactionCount: number;
75
63
  lastInteraction: number | null;
@@ -47,20 +47,6 @@ mock.module("../../runtime/assistant-event-hub.js", () => ({
47
47
  broadcastMessage: () => {},
48
48
  }));
49
49
 
50
- // Control the advisor profile gate to verify the advisor tool is wired to it.
51
- // The gate's own config semantics (default-on, active-profile fallback) are
52
- // covered by advisor-gate.test.ts; here we only assert the wiring and the
53
- // profile argument isToolActiveForContext passes through.
54
- let advisorGateResult = true;
55
- const advisorGateProfiles: (string | null)[] = [];
56
-
57
- mock.module("../../plugins/defaults/advisor/advisor-gate.js", () => ({
58
- advisorEnabledForProfile: (profile: string | null) => {
59
- advisorGateProfiles.push(profile);
60
- return advisorGateResult;
61
- },
62
- }));
63
-
64
50
  // Dynamic imports after mock.module calls so the stubs take effect
65
51
  // before the modules under test are loaded.
66
52
  const { HOST_TOOL_NAMES, HOST_TOOL_TO_CAPABILITY, isToolActiveForContext } =
@@ -611,36 +597,6 @@ describe("isToolActiveForContext — ask_question macOS gating", () => {
611
597
  });
612
598
  });
613
599
 
614
- describe("isToolActiveForContext — advisor profile gate", () => {
615
- beforeEach(() => {
616
- advisorGateResult = true;
617
- advisorGateProfiles.length = 0;
618
- });
619
-
620
- test("advisor is active when the profile enables it", () => {
621
- advisorGateResult = true;
622
- expect(isToolActiveForContext("advisor", makeCtx())).toBe(true);
623
- });
624
-
625
- test("advisor is NOT active when the profile disables it", () => {
626
- advisorGateResult = false;
627
- expect(isToolActiveForContext("advisor", makeCtx())).toBe(false);
628
- });
629
-
630
- test("consults the gate with the per-turn override profile", () => {
631
- isToolActiveForContext(
632
- "advisor",
633
- makeCtx({ currentTurnOverrideProfile: "cost-optimized" }),
634
- );
635
- expect(advisorGateProfiles).toEqual(["cost-optimized"]);
636
- });
637
-
638
- test("consults the gate with null when no per-turn override is set", () => {
639
- isToolActiveForContext("advisor", makeCtx());
640
- expect(advisorGateProfiles).toEqual([null]);
641
- });
642
- });
643
-
644
600
  describe("HOST_TOOL_NAMES derivation", () => {
645
601
  test("HOST_TOOL_NAMES is derived from HOST_TOOL_TO_CAPABILITY", () => {
646
602
  // Sanity check: every tool in the names set has a capability mapping.
@@ -29,6 +29,7 @@ import {
29
29
  getMessageById,
30
30
  messageMetadataSchema,
31
31
  provenanceFromTrustContext,
32
+ recordConversationPersistedSeq,
32
33
  reserveMessage,
33
34
  setConversationHistoryStrippedAt,
34
35
  setLastNotifiedInferenceProfile,
@@ -57,10 +58,7 @@ import type {
57
58
  ImageContent,
58
59
  Message,
59
60
  } from "../providers/types.js";
60
- import {
61
- getCurrentSeq,
62
- recordPersistedSeq,
63
- } from "../runtime/assistant-stream-state.js";
61
+ import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
64
62
  import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
65
63
  import { redactSecrets } from "../security/secret-scanner.js";
66
64
  import { extractDomain } from "../tools/network/domain-normalize.js";
@@ -104,6 +102,12 @@ import type {
104
102
 
105
103
  const log = getLogger("agent-loop-handlers");
106
104
 
105
+ function shouldPersistProviderErrorAsAssistantMessage(classified: {
106
+ code: string;
107
+ }): boolean {
108
+ return classified.code !== "MANAGED_KEY_INVALID";
109
+ }
110
+
107
111
  /**
108
112
  * Persist the history-stripped marker after the loop strips runtime injections
109
113
  * for compaction / overflow recovery. The marker is a durability hint, not
@@ -163,6 +167,7 @@ export interface EventHandlerState {
163
167
  readonly exchangeRawResponses: unknown[];
164
168
  model: string;
165
169
  providerErrorUserMessage: string | null;
170
+ persistProviderErrorAsAssistantMessage: boolean;
166
171
  lastAssistantMessageId: string | undefined;
167
172
  /**
168
173
  * True when `handleLlmCallStarted` has reserved an empty assistant row
@@ -341,6 +346,7 @@ export function createEventHandlerState(): EventHandlerState {
341
346
  exchangeRawResponses: [],
342
347
  model: "",
343
348
  providerErrorUserMessage: null,
349
+ persistProviderErrorAsAssistantMessage: false,
344
350
  lastAssistantMessageId: undefined,
345
351
  assistantRowAwaitingFinalization: false,
346
352
  pendingToolResults: new Map(),
@@ -530,7 +536,7 @@ async function flushAccumulatedContent(
530
536
  // Record only after the write commits, so the snapshot seq never
531
537
  // claims content that is not yet durable.
532
538
  if (flushedSeq != null) {
533
- recordPersistedSeq(deps.ctx.conversationId, flushedSeq);
539
+ recordConversationPersistedSeq(deps.ctx.conversationId, flushedSeq);
534
540
  }
535
541
  } catch (err) {
536
542
  deps.rlog.warn(
@@ -918,7 +924,7 @@ export function handleToolUse(
918
924
  // persisted seq to it. Without this the snapshot would advertise a seq below
919
925
  // an event it already incorporates, and a client applying `seq > snapshot.seq`
920
926
  // would replay this tool start.
921
- recordPersistedSeq(deps.ctx.conversationId, getCurrentSeq());
927
+ recordConversationPersistedSeq(deps.ctx.conversationId, getCurrentSeq());
922
928
  }
923
929
 
924
930
  export function handleToolUsePreviewStart(
@@ -1136,7 +1142,7 @@ async function persistPendingToolResultRow(
1136
1142
  rowId,
1137
1143
  JSON.stringify(buildToolResultBlocks(state.pendingToolResults)),
1138
1144
  );
1139
- recordPersistedSeq(deps.ctx.conversationId, seq);
1145
+ recordConversationPersistedSeq(deps.ctx.conversationId, seq);
1140
1146
  const conv = getConversation(deps.ctx.conversationId);
1141
1147
  if (conv != null) {
1142
1148
  syncMessageToDisk(deps.ctx.conversationId, rowId, conv.createdAt);
@@ -1618,6 +1624,8 @@ function handleError(
1618
1624
  buildConversationErrorMessage(deps.ctx.conversationId, classified),
1619
1625
  );
1620
1626
  state.providerErrorUserMessage = classified.userMessage;
1627
+ state.persistProviderErrorAsAssistantMessage =
1628
+ shouldPersistProviderErrorAsAssistantMessage(classified);
1621
1629
  }
1622
1630
 
1623
1631
  export function handleMaxTokensReached(
@@ -1816,10 +1824,14 @@ export async function handleMessageComplete(
1816
1824
  // delta's seq -- the highest stamped content event this row reflects -- so
1817
1825
  // recording it is honest. A drained tool result was stamped earlier in the
1818
1826
  // turn, so this seq already covers it; a call that streams no content (a
1819
- // pure tool call) advances instead via `tool_use_start`. `recordPersistedSeq`
1820
- // clamps monotonically, so a lower value here never regresses the seq.
1827
+ // pure tool call) advances instead via `tool_use_start`.
1828
+ // `recordConversationPersistedSeq` clamps monotonically, so a lower value
1829
+ // here never regresses the seq.
1821
1830
  if (state.lastPersistedContentSeq != null) {
1822
- recordPersistedSeq(deps.ctx.conversationId, state.lastPersistedContentSeq);
1831
+ recordConversationPersistedSeq(
1832
+ deps.ctx.conversationId,
1833
+ state.lastPersistedContentSeq,
1834
+ );
1823
1835
  }
1824
1836
  // Reset the partial-persist mirror so subsequent calls in this turn
1825
1837
  // start with an empty running view.
@@ -2127,6 +2139,13 @@ function handleProviderError(
2127
2139
  deps: EventHandlerDeps,
2128
2140
  event: Extract<AgentEvent, { type: "provider_error" }>,
2129
2141
  ): void {
2142
+ const classified = classifyConversationError(event.error, {
2143
+ phase: "agent_loop",
2144
+ });
2145
+ if (!shouldPersistProviderErrorAsAssistantMessage(classified)) {
2146
+ return;
2147
+ }
2148
+
2130
2149
  try {
2131
2150
  recordRequestLog(
2132
2151
  deps.ctx.conversationId,