@vellumai/assistant 0.3.28 → 0.4.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 (201) hide show
  1. package/ARCHITECTURE.md +33 -3
  2. package/bun.lock +4 -1
  3. package/docs/trusted-contact-access.md +9 -2
  4. package/package.json +6 -3
  5. package/scripts/ipc/generate-swift.ts +3 -3
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  7. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +13 -5
  9. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  10. package/src/__tests__/asset-search-tool.test.ts +2 -0
  11. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  12. package/src/__tests__/attachments-store.test.ts +2 -0
  13. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  14. package/src/__tests__/call-controller.test.ts +30 -29
  15. package/src/__tests__/call-routes-http.test.ts +34 -32
  16. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  17. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  18. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  19. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  20. package/src/__tests__/clarification-resolver.test.ts +2 -0
  21. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  22. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  24. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  25. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  26. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  27. package/src/__tests__/config-schema.test.ts +5 -5
  28. package/src/__tests__/config-watcher.test.ts +3 -1
  29. package/src/__tests__/connection-policy.test.ts +14 -5
  30. package/src/__tests__/contacts-tools.test.ts +3 -1
  31. package/src/__tests__/contradiction-checker.test.ts +2 -0
  32. package/src/__tests__/conversation-pairing.test.ts +10 -0
  33. package/src/__tests__/conversation-routes.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  35. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  36. package/src/__tests__/credential-vault.test.ts +5 -4
  37. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  38. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  39. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  40. package/src/__tests__/encrypted-store.test.ts +10 -5
  41. package/src/__tests__/followup-tools.test.ts +3 -1
  42. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  43. package/src/__tests__/gmail-integration.test.ts +0 -1
  44. package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
  45. package/src/__tests__/guardian-dispatch.test.ts +2 -0
  46. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  47. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  48. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  49. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  50. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  51. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  52. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  53. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  54. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  55. package/src/__tests__/heartbeat-service.test.ts +20 -0
  56. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  57. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  58. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  59. package/src/__tests__/intent-routing.test.ts +2 -0
  60. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  61. package/src/__tests__/media-generate-image.test.ts +21 -0
  62. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  63. package/src/__tests__/memory-regressions.test.ts +20 -20
  64. package/src/__tests__/non-member-access-request.test.ts +183 -9
  65. package/src/__tests__/notification-decision-fallback.test.ts +2 -0
  66. package/src/__tests__/notification-decision-strategy.test.ts +61 -0
  67. package/src/__tests__/notification-guardian-path.test.ts +2 -0
  68. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  69. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  70. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  71. package/src/__tests__/pairing-routes.test.ts +171 -0
  72. package/src/__tests__/playbook-execution.test.ts +3 -1
  73. package/src/__tests__/playbook-tools.test.ts +3 -1
  74. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  75. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  76. package/src/__tests__/recording-handler.test.ts +11 -0
  77. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  78. package/src/__tests__/recording-state-machine.test.ts +13 -2
  79. package/src/__tests__/registry.test.ts +7 -3
  80. package/src/__tests__/relay-server.test.ts +148 -28
  81. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  82. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  83. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  84. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  85. package/src/__tests__/schedule-tools.test.ts +3 -1
  86. package/src/__tests__/send-endpoint-busy.test.ts +288 -0
  87. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  88. package/src/__tests__/session-agent-loop.test.ts +16 -0
  89. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  90. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  91. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  92. package/src/__tests__/session-profile-injection.test.ts +21 -0
  93. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  94. package/src/__tests__/session-queue.test.ts +23 -0
  95. package/src/__tests__/session-runtime-assembly.test.ts +50 -12
  96. package/src/__tests__/session-skill-tools.test.ts +27 -5
  97. package/src/__tests__/session-slash-known.test.ts +23 -0
  98. package/src/__tests__/session-slash-queue.test.ts +23 -0
  99. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  100. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  101. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  102. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  103. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  104. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  105. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  106. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  107. package/src/__tests__/skills.test.ts +8 -4
  108. package/src/__tests__/slack-channel-config.test.ts +3 -1
  109. package/src/__tests__/subagent-tools.test.ts +19 -0
  110. package/src/__tests__/swarm-recursion.test.ts +2 -0
  111. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  112. package/src/__tests__/swarm-tool.test.ts +2 -0
  113. package/src/__tests__/system-prompt.test.ts +3 -1
  114. package/src/__tests__/task-compiler.test.ts +3 -1
  115. package/src/__tests__/task-management-tools.test.ts +3 -1
  116. package/src/__tests__/task-tools.test.ts +3 -1
  117. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  118. package/src/__tests__/terminal-tools.test.ts +2 -0
  119. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  120. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  121. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  122. package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
  123. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  124. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  125. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  126. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  127. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  128. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  129. package/src/__tests__/view-image-tool.test.ts +3 -1
  130. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  131. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  132. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  133. package/src/__tests__/work-item-output.test.ts +3 -1
  134. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  135. package/src/calls/call-controller.ts +26 -23
  136. package/src/calls/guardian-action-sweep.ts +10 -2
  137. package/src/calls/relay-server.ts +216 -27
  138. package/src/calls/types.ts +1 -1
  139. package/src/calls/voice-session-bridge.ts +3 -3
  140. package/src/cli.ts +12 -0
  141. package/src/config/agent-schema.ts +14 -3
  142. package/src/config/calls-schema.ts +6 -6
  143. package/src/config/core-schema.ts +3 -3
  144. package/src/config/feature-flag-registry.json +8 -0
  145. package/src/config/mcp-schema.ts +1 -1
  146. package/src/config/memory-schema.ts +27 -19
  147. package/src/config/schema.ts +21 -21
  148. package/src/config/skills-schema.ts +7 -7
  149. package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
  150. package/src/daemon/handlers/config-inbox.ts +4 -4
  151. package/src/daemon/handlers/sessions.ts +148 -4
  152. package/src/daemon/ipc-contract/messages.ts +16 -0
  153. package/src/daemon/ipc-contract-inventory.json +1 -0
  154. package/src/daemon/lifecycle.ts +19 -0
  155. package/src/daemon/pairing-store.ts +86 -3
  156. package/src/daemon/response-tier.ts +6 -5
  157. package/src/daemon/session-agent-loop.ts +5 -5
  158. package/src/daemon/session-lifecycle.ts +25 -17
  159. package/src/daemon/session-memory.ts +2 -2
  160. package/src/daemon/session-process.ts +1 -20
  161. package/src/daemon/session-runtime-assembly.ts +28 -22
  162. package/src/daemon/session-tool-setup.ts +2 -2
  163. package/src/daemon/session.ts +3 -3
  164. package/src/memory/canonical-guardian-store.ts +63 -1
  165. package/src/memory/channel-guardian-store.ts +1 -0
  166. package/src/memory/conversation-crud.ts +7 -7
  167. package/src/memory/db-init.ts +4 -0
  168. package/src/memory/embedding-local.ts +257 -39
  169. package/src/memory/embedding-runtime-manager.ts +471 -0
  170. package/src/memory/guardian-bindings.ts +25 -1
  171. package/src/memory/indexer.ts +3 -3
  172. package/src/memory/ingress-invite-store.ts +45 -0
  173. package/src/memory/job-handlers/backfill.ts +16 -9
  174. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  175. package/src/memory/migrations/index.ts +1 -0
  176. package/src/memory/qdrant-client.ts +31 -22
  177. package/src/memory/schema.ts +4 -0
  178. package/src/notifications/copy-composer.ts +15 -0
  179. package/src/runtime/access-request-helper.ts +43 -7
  180. package/src/runtime/actor-trust-resolver.ts +46 -50
  181. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  182. package/src/runtime/channel-retry-sweep.ts +18 -6
  183. package/src/runtime/guardian-context-resolver.ts +38 -96
  184. package/src/runtime/guardian-reply-router.ts +31 -1
  185. package/src/runtime/ingress-service.ts +80 -3
  186. package/src/runtime/invite-redemption-service.ts +141 -2
  187. package/src/runtime/routes/channel-route-shared.ts +1 -1
  188. package/src/runtime/routes/channel-routes.ts +1 -1
  189. package/src/runtime/routes/conversation-routes.ts +166 -2
  190. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  191. package/src/runtime/routes/inbound-message-handler.ts +41 -10
  192. package/src/runtime/routes/ingress-routes.ts +52 -4
  193. package/src/runtime/routes/pairing-routes.ts +3 -0
  194. package/src/tools/guardian-control-plane-policy.ts +2 -2
  195. package/src/tools/reminder/reminder-store.ts +10 -14
  196. package/src/tools/tool-approval-handler.ts +11 -11
  197. package/src/tools/types.ts +2 -2
  198. package/src/util/logger.ts +20 -8
  199. package/src/util/platform.ts +10 -0
  200. package/src/util/voice-code.ts +29 -0
  201. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -38,6 +38,7 @@ export { createScopedApprovalGrantsTable } from './033-scoped-approval-grants.js
38
38
  export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-metadata.js';
39
39
  export { migrateGuardianActionSupersession } from './035-guardian-action-supersession.js';
40
40
  export { migrateNormalizePhoneIdentities } from './036-normalize-phone-identities.js';
41
+ export { migrateVoiceInviteColumns } from './037-voice-invite-columns.js';
41
42
  export { createCoreTables } from './100-core-tables.js';
42
43
  export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
43
44
  export { addCoreColumns } from './102-alter-table-columns.js';
@@ -80,28 +80,37 @@ export class VellumQdrantClient {
80
80
 
81
81
  log.info({ collection: this.collection, vectorSize: this.vectorSize }, 'Creating Qdrant collection');
82
82
 
83
- await this.client.createCollection(this.collection, {
84
- vectors: {
85
- size: this.vectorSize,
86
- distance: 'Cosine',
87
- on_disk: this.onDisk,
88
- },
89
- hnsw_config: {
90
- on_disk: this.onDisk,
91
- m: 16,
92
- ef_construct: 100,
93
- },
94
- quantization_config: this.quantization === 'scalar'
95
- ? {
96
- scalar: {
97
- type: 'int8',
98
- quantile: 0.99,
99
- always_ram: true,
100
- },
101
- }
102
- : undefined,
103
- on_disk_payload: this.onDisk,
104
- });
83
+ try {
84
+ await this.client.createCollection(this.collection, {
85
+ vectors: {
86
+ size: this.vectorSize,
87
+ distance: 'Cosine',
88
+ on_disk: this.onDisk,
89
+ },
90
+ hnsw_config: {
91
+ on_disk: this.onDisk,
92
+ m: 16,
93
+ ef_construct: 100,
94
+ },
95
+ quantization_config: this.quantization === 'scalar'
96
+ ? {
97
+ scalar: {
98
+ type: 'int8',
99
+ quantile: 0.99,
100
+ always_ram: true,
101
+ },
102
+ }
103
+ : undefined,
104
+ on_disk_payload: this.onDisk,
105
+ });
106
+ } catch (err) {
107
+ // 409 = collection was created by a concurrent caller — that's fine
108
+ if (err instanceof Error && 'status' in err && (err as { status: number }).status === 409) {
109
+ this.collectionReady = true;
110
+ return;
111
+ }
112
+ throw err;
113
+ }
105
114
 
106
115
  // Create payload indexes for efficient filtering
107
116
  await Promise.all([
@@ -942,6 +942,10 @@ export const assistantIngressInvites = sqliteTable('assistant_ingress_invites',
942
942
  redeemedByExternalUserId: text('redeemed_by_external_user_id'),
943
943
  redeemedByExternalChatId: text('redeemed_by_external_chat_id'),
944
944
  redeemedAt: integer('redeemed_at'),
945
+ // Voice invite fields (nullable — non-voice invites leave these NULL)
946
+ expectedExternalUserId: text('expected_external_user_id'),
947
+ voiceCodeHash: text('voice_code_hash'),
948
+ voiceCodeDigits: integer('voice_code_digits'),
945
949
  createdAt: integer('created_at').notNull(),
946
950
  updatedAt: integer('updated_at').notNull(),
947
951
  });
@@ -54,6 +54,21 @@ const TEMPLATES: Record<string, CopyTemplate> = {
54
54
  };
55
55
  },
56
56
 
57
+ 'ingress.access_request': (payload) => {
58
+ const requester = str(payload.senderIdentifier, 'Someone');
59
+ const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
60
+ const lines: string[] = [`${requester} is requesting access to the assistant.`];
61
+ if (requestCode) {
62
+ const code = requestCode.toUpperCase();
63
+ lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
64
+ }
65
+ lines.push('Reply "open invite flow" to start Trusted Contacts invite flow.');
66
+ return {
67
+ title: 'Access Request',
68
+ body: lines.join('\n'),
69
+ };
70
+ },
71
+
57
72
  'ingress.escalation': (payload) => ({
58
73
  title: 'Escalation',
59
74
  body: str(payload.senderIdentifier, 'An incoming message') + ' needs attention',
@@ -4,6 +4,13 @@
4
4
  * Encapsulates the "create/dedupe canonical access request + emit notification"
5
5
  * logic so both text-channel and voice-channel ingress paths use identical
6
6
  * guardian notification flows.
7
+ *
8
+ * Access requests are a special case: they always create a canonical request
9
+ * and emit a notification signal, even when no same-channel guardian binding
10
+ * exists. Guardian binding resolution uses a fallback strategy:
11
+ * 1. Source-channel active binding first.
12
+ * 2. Any active binding for the assistant (deterministic, most-recently-verified).
13
+ * 3. No guardian identity (trusted/vellum-only resolution path).
7
14
  */
8
15
 
9
16
  import type { ChannelId } from '../channels/types.js';
@@ -11,6 +18,7 @@ import {
11
18
  createCanonicalGuardianRequest,
12
19
  listCanonicalGuardianRequests,
13
20
  } from '../memory/canonical-guardian-store.js';
21
+ import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
14
22
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
15
23
  import { getLogger } from '../util/logger.js';
16
24
  import { getGuardianBinding } from './channel-guardian-service.js';
@@ -33,7 +41,7 @@ export interface AccessRequestParams {
33
41
 
34
42
  export type AccessRequestResult =
35
43
  | { notified: true; created: boolean; requestId: string }
36
- | { notified: false; reason: 'no_sender_id' | 'no_guardian_binding' };
44
+ | { notified: false; reason: 'no_sender_id' };
37
45
 
38
46
  // ---------------------------------------------------------------------------
39
47
  // Helper
@@ -46,6 +54,10 @@ export type AccessRequestResult =
46
54
  * Returns a result indicating whether the guardian was notified and whether
47
55
  * a new request was created or an existing one was deduped.
48
56
  *
57
+ * Guardian binding resolution: source-channel first, then any active binding
58
+ * for the assistant, then null (notification pipeline handles delivery via
59
+ * trusted/vellum channels when no binding exists).
60
+ *
49
61
  * This is intentionally synchronous with respect to the canonical store writes
50
62
  * and fire-and-forget for the notification signal emission.
51
63
  */
@@ -65,10 +77,32 @@ export function notifyGuardianOfAccessRequest(
65
77
  return { notified: false, reason: 'no_sender_id' };
66
78
  }
67
79
 
68
- const binding = getGuardianBinding(canonicalAssistantId, sourceChannel);
69
- if (!binding) {
70
- log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification');
71
- return { notified: false, reason: 'no_guardian_binding' };
80
+ // Resolve guardian binding with fallback strategy:
81
+ // 1. Source-channel active binding
82
+ // 2. Any active binding for the assistant (deterministic order)
83
+ // 3. null (no guardian identity — notification pipeline uses trusted channels)
84
+ const sourceBinding = getGuardianBinding(canonicalAssistantId, sourceChannel);
85
+ let guardianExternalUserId: string | null = null;
86
+ let guardianBindingChannel: string | null = null;
87
+
88
+ if (sourceBinding) {
89
+ guardianExternalUserId = sourceBinding.guardianExternalUserId;
90
+ guardianBindingChannel = sourceBinding.channel;
91
+ } else {
92
+ const allBindings = listActiveBindingsByAssistant(canonicalAssistantId);
93
+ if (allBindings.length > 0) {
94
+ guardianExternalUserId = allBindings[0].guardianExternalUserId;
95
+ guardianBindingChannel = allBindings[0].channel;
96
+ log.debug(
97
+ { sourceChannel, fallbackChannel: guardianBindingChannel, canonicalAssistantId },
98
+ 'Using cross-channel guardian binding fallback for access request',
99
+ );
100
+ } else {
101
+ log.debug(
102
+ { sourceChannel, canonicalAssistantId },
103
+ 'No guardian binding for access request — proceeding without guardian identity',
104
+ );
105
+ }
72
106
  }
73
107
 
74
108
  // Deduplicate: skip creation if there is already a pending canonical request
@@ -99,7 +133,7 @@ export function notifyGuardianOfAccessRequest(
99
133
  conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
100
134
  requesterExternalUserId: senderExternalUserId,
101
135
  requesterChatId: externalChatId,
102
- guardianExternalUserId: binding.guardianExternalUserId,
136
+ guardianExternalUserId: guardianExternalUserId ?? undefined,
103
137
  toolName: 'ingress_access_request',
104
138
  questionText: `${senderIdentifier} is requesting access to the assistant`,
105
139
  expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
@@ -118,18 +152,20 @@ export function notifyGuardianOfAccessRequest(
118
152
  },
119
153
  contextPayload: {
120
154
  requestId,
155
+ requestCode: canonicalRequest.requestCode,
121
156
  sourceChannel,
122
157
  externalChatId,
123
158
  senderExternalUserId,
124
159
  senderName: senderName ?? null,
125
160
  senderUsername: senderUsername ?? null,
126
161
  senderIdentifier,
162
+ guardianBindingChannel,
127
163
  },
128
164
  dedupeKey: `access-request:${canonicalRequest.id}`,
129
165
  });
130
166
 
131
167
  log.info(
132
- { sourceChannel, senderExternalUserId, senderIdentifier },
168
+ { sourceChannel, senderExternalUserId, senderIdentifier, guardianBindingChannel },
133
169
  'Guardian notified of access request',
134
170
  );
135
171
 
@@ -10,25 +10,22 @@
10
10
  * - `guardian`: sender matches the active guardian binding for this channel.
11
11
  * - `trusted_contact`: sender is an active ingress member (not the guardian).
12
12
  * - `unknown`: sender has no member record or no identity could be established.
13
- *
14
- * The legacy `ActorRole` enum (`guardian` / `non-guardian` / `unverified_channel`)
15
- * is still required by existing policy gates. The `toLegacyActorRole()` mapper
16
- * converts the new trust classification to the legacy enum.
17
13
  */
18
14
 
19
15
  import type { ChannelId } from '../channels/types.js';
16
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
20
17
  import type { IngressMember } from '../memory/ingress-member-store.js';
21
18
  import { findMember } from '../memory/ingress-member-store.js';
22
19
  import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
23
20
  import { normalizeAssistantId } from '../util/platform.js';
24
21
  import { getGuardianBinding } from './channel-guardian-service.js';
25
- import type { ActorRole, DenialReason, GuardianContext } from './guardian-context-resolver.js';
26
22
 
27
23
  // ---------------------------------------------------------------------------
28
24
  // Types
29
25
  // ---------------------------------------------------------------------------
30
26
 
31
27
  export type TrustClass = 'guardian' | 'trusted_contact' | 'unknown';
28
+ export type DenialReason = 'no_binding' | 'no_identity';
32
29
 
33
30
  export interface ActorTrustContext {
34
31
  /** Canonical (normalized) sender identity. Null when identity could not be established. */
@@ -46,6 +43,8 @@ export interface ActorTrustContext {
46
43
  actorMetadata: {
47
44
  identifier: string | undefined;
48
45
  displayName: string | undefined;
46
+ senderDisplayName: string | undefined;
47
+ memberDisplayName: string | undefined;
49
48
  username: string | undefined;
50
49
  channel: ChannelId;
51
50
  trustStatus: TrustClass;
@@ -108,6 +107,8 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
108
107
  actorMetadata: {
109
108
  identifier,
110
109
  displayName: senderDisplayName,
110
+ senderDisplayName,
111
+ memberDisplayName: undefined,
111
112
  username: senderUsername,
112
113
  channel: input.sourceChannel,
113
114
  trustStatus: 'unknown',
@@ -139,11 +140,33 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
139
140
  externalChatId: input.externalChatId,
140
141
  });
141
142
 
143
+ // In group chats, findMember may match on externalChatId and return a
144
+ // record for a different user. Only use member metadata when the record's
145
+ // externalUserId matches the current sender to avoid misidentification.
146
+ // Canonicalize the stored member ID to handle formatting variance (e.g.
147
+ // phone numbers stored without E.164 normalization).
148
+ const memberMatchesSender = memberRecord?.externalUserId
149
+ ? canonicalizeInboundIdentity(input.sourceChannel, memberRecord.externalUserId) === canonicalSenderId
150
+ : false;
151
+
152
+ const memberUsername = memberMatchesSender && typeof memberRecord?.username === 'string' && memberRecord.username.trim().length > 0
153
+ ? memberRecord.username.trim()
154
+ : undefined;
155
+ const memberDisplayName = memberMatchesSender && typeof memberRecord?.displayName === 'string' && memberRecord.displayName.trim().length > 0
156
+ ? memberRecord.displayName.trim()
157
+ : undefined;
158
+ // Prefer member profile metadata over transient sender metadata so guardian-
159
+ // curated contact details are canonical for assistant-facing identity —
160
+ // but only when the member record actually belongs to the current sender.
161
+ const resolvedUsername = memberUsername ?? senderUsername;
162
+ const resolvedDisplayName = memberDisplayName ?? senderDisplayName;
163
+ const resolvedIdentifier = resolvedUsername ? `@${resolvedUsername}` : canonicalSenderId ?? undefined;
164
+
142
165
  // Trust classification
143
166
  let trustClass: TrustClass;
144
167
  if (isGuardian) {
145
168
  trustClass = 'guardian';
146
- } else if (memberRecord && memberRecord.status === 'active') {
169
+ } else if (memberMatchesSender && memberRecord && memberRecord.status === 'active') {
147
170
  trustClass = 'trusted_contact';
148
171
  } else {
149
172
  trustClass = 'unknown';
@@ -161,9 +184,11 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
161
184
  memberRecord,
162
185
  trustClass,
163
186
  actorMetadata: {
164
- identifier,
165
- displayName: senderDisplayName,
166
- username: senderUsername,
187
+ identifier: resolvedIdentifier,
188
+ displayName: resolvedDisplayName,
189
+ senderDisplayName,
190
+ memberDisplayName,
191
+ username: resolvedUsername,
167
192
  channel: input.sourceChannel,
168
193
  trustStatus: trustClass,
169
194
  },
@@ -171,53 +196,24 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
171
196
  };
172
197
  }
173
198
 
174
- // ---------------------------------------------------------------------------
175
- // Legacy compatibility mapper
176
- // ---------------------------------------------------------------------------
177
-
178
- /**
179
- * Map the new trust classification to the legacy ActorRole enum used by
180
- * existing policy gates. This preserves backward compatibility while the
181
- * codebase migrates to the unified trust model.
182
- *
183
- * Mapping:
184
- * - guardian => 'guardian'
185
- * - trusted_contact => 'non-guardian' (existing gates treat active members as non-guardian)
186
- * - unknown (no_identity) => 'unverified_channel'
187
- * - unknown (no_binding) => 'unverified_channel'
188
- * - unknown (with binding, not guardian) => 'non-guardian'
189
- */
190
- export function toLegacyActorRole(ctx: ActorTrustContext): ActorRole {
191
- if (ctx.trustClass === 'guardian') return 'guardian';
192
- if (ctx.trustClass === 'trusted_contact') return 'non-guardian';
193
-
194
- // unknown: distinguish between unverified_channel and non-guardian
195
- if (ctx.denialReason === 'no_identity' || ctx.denialReason === 'no_binding') {
196
- return 'unverified_channel';
197
- }
198
-
199
- // Has a binding, has identity, but not guardian and not a member => non-guardian
200
- if (ctx.guardianBindingMatch && ctx.canonicalSenderId) {
201
- return 'non-guardian';
202
- }
203
-
204
- return 'unverified_channel';
205
- }
206
-
207
199
  /**
208
- * Convert an ActorTrustContext into the legacy GuardianContext shape that
209
- * existing route-level code expects. This is a bridge for incremental
210
- * migration — new code should consume ActorTrustContext directly.
200
+ * Convert an ActorTrustContext into the runtime trust context shape used by
201
+ * sessions/tooling.
211
202
  */
212
- export function toGuardianContextCompat(ctx: ActorTrustContext, externalChatId: string): GuardianContext {
213
- const actorRole = toLegacyActorRole(ctx);
214
-
203
+ export function toGuardianRuntimeContextFromTrust(
204
+ ctx: ActorTrustContext,
205
+ externalChatId: string,
206
+ ): GuardianRuntimeContext {
215
207
  return {
216
- actorRole,
208
+ sourceChannel: ctx.actorMetadata.channel,
209
+ trustClass: ctx.trustClass,
217
210
  guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
218
- (actorRole === 'guardian' ? externalChatId : undefined),
211
+ (ctx.trustClass === 'guardian' ? externalChatId : undefined),
219
212
  guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
220
213
  requesterIdentifier: ctx.actorMetadata.identifier,
214
+ requesterDisplayName: ctx.actorMetadata.displayName,
215
+ requesterSenderDisplayName: ctx.actorMetadata.senderDisplayName,
216
+ requesterMemberDisplayName: ctx.actorMetadata.memberDisplayName,
221
217
  requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
222
218
  requesterChatId: externalChatId,
223
219
  denialReason: ctx.denialReason,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Voice channel invite transport adapter.
3
+ *
4
+ * Voice invites are identity-bound: the invitee must call from a specific
5
+ * phone number and enter a numeric code. Unlike Telegram invites, there is
6
+ * no shareable deep link — the guardian relays the code and calling
7
+ * instructions verbally or via another channel.
8
+ *
9
+ * The transport builds human-readable instruction text and provides a
10
+ * no-op token extractor since voice invite redemption uses the dedicated
11
+ * voice-code path rather than generic token extraction.
12
+ */
13
+
14
+ import type { ChannelId } from '../../channels/types.js';
15
+ import {
16
+ type ChannelInviteTransport,
17
+ type InviteSharePayload,
18
+ registerTransport,
19
+ } from '../channel-invite-transport.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Transport implementation
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const voiceInviteTransport: ChannelInviteTransport = {
26
+ channel: 'voice' as ChannelId,
27
+
28
+ buildShareableInvite(_params: {
29
+ rawToken: string;
30
+ sourceChannel: ChannelId;
31
+ }): InviteSharePayload {
32
+ // Voice invites do not produce a clickable URL. The "url" field contains
33
+ // a placeholder — callers should use displayText for presentation.
34
+ return {
35
+ url: '',
36
+ displayText: [
37
+ 'Voice invite created.',
38
+ 'The invitee must call the assistant\'s phone number from the authorized number and enter their invite code when prompted.',
39
+ ].join(' '),
40
+ };
41
+ },
42
+
43
+ extractInboundToken(_params: {
44
+ commandIntent?: Record<string, unknown>;
45
+ content: string;
46
+ sourceMetadata?: Record<string, unknown>;
47
+ }): string | undefined {
48
+ // Voice invite redemption bypasses generic token extraction — it uses
49
+ // the identity-bound voice-code flow in invite-redemption-service.ts.
50
+ return undefined;
51
+ },
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Auto-register on import
56
+ // ---------------------------------------------------------------------------
57
+
58
+ registerTransport(voiceInviteTransport);
@@ -14,11 +14,20 @@ const log = getLogger('runtime-http');
14
14
  function parseGuardianRuntimeContext(value: unknown): GuardianRuntimeContext | undefined {
15
15
  if (!value || typeof value !== 'object') return undefined;
16
16
  const raw = value as Record<string, unknown>;
17
- const actorRole = raw.actorRole;
17
+ const trustClass = raw.trustClass
18
+ ?? (
19
+ raw.actorRole === 'guardian'
20
+ ? 'guardian'
21
+ : raw.actorRole === 'non-guardian'
22
+ ? 'trusted_contact'
23
+ : raw.actorRole === 'unverified_channel'
24
+ ? 'unknown'
25
+ : undefined
26
+ );
18
27
  if (
19
- actorRole !== 'guardian'
20
- && actorRole !== 'non-guardian'
21
- && actorRole !== 'unverified_channel'
28
+ trustClass !== 'guardian'
29
+ && trustClass !== 'trusted_contact'
30
+ && trustClass !== 'unknown'
22
31
  ) {
23
32
  return undefined;
24
33
  }
@@ -33,10 +42,13 @@ function parseGuardianRuntimeContext(value: unknown): GuardianRuntimeContext | u
33
42
  : undefined;
34
43
  return {
35
44
  sourceChannel,
36
- actorRole,
45
+ trustClass,
37
46
  guardianChatId: typeof raw.guardianChatId === 'string' ? raw.guardianChatId : undefined,
38
47
  guardianExternalUserId: typeof raw.guardianExternalUserId === 'string' ? raw.guardianExternalUserId : undefined,
39
48
  requesterIdentifier: typeof raw.requesterIdentifier === 'string' ? raw.requesterIdentifier : undefined,
49
+ requesterDisplayName: typeof raw.requesterDisplayName === 'string' ? raw.requesterDisplayName : undefined,
50
+ requesterSenderDisplayName: typeof raw.requesterSenderDisplayName === 'string' ? raw.requesterSenderDisplayName : undefined,
51
+ requesterMemberDisplayName: typeof raw.requesterMemberDisplayName === 'string' ? raw.requesterMemberDisplayName : undefined,
40
52
  requesterExternalUserId: typeof raw.requesterExternalUserId === 'string' ? raw.requesterExternalUserId : undefined,
41
53
  requesterChatId: typeof raw.requesterChatId === 'string' ? raw.requesterChatId : undefined,
42
54
  denialReason,
@@ -117,7 +129,7 @@ export async function sweepFailedEvents(
117
129
  },
118
130
  assistantId,
119
131
  guardianContext,
120
- isInteractive: guardianContext?.actorRole === 'guardian',
132
+ isInteractive: guardianContext?.trustClass === 'guardian',
121
133
  },
122
134
  sourceChannel,
123
135
  sourceInterface,
@@ -1,131 +1,73 @@
1
1
  /**
2
- * Shared guardian actor-role resolution for inbound channels.
2
+ * Shared inbound trust resolution for channel actors.
3
3
  *
4
- * This module centralizes how we classify an inbound actor as
5
- * guardian/non-guardian/unverified so every channel path uses the same
6
- * source-of-truth logic.
7
- *
8
- * Guardian binding comparisons now use canonicalized identities (E.164 for
9
- * phone-like channels) to eliminate formatting-variance mismatches.
4
+ * This module provides a compact route-level shape used by channel routes
5
+ * while delegating canonical classification to the unified actor trust
6
+ * resolver.
10
7
  */
11
8
  import type { ChannelId } from '../channels/types.js';
12
9
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
13
- import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
14
- import { normalizeAssistantId } from '../util/platform.js';
15
- import { getGuardianBinding } from './channel-guardian-service.js';
10
+ import {
11
+ type DenialReason,
12
+ resolveActorTrust,
13
+ type ResolveActorTrustInput,
14
+ type TrustClass,
15
+ } from './actor-trust-resolver.js';
16
+ export type { DenialReason } from './actor-trust-resolver.js';
16
17
 
17
- /** Sub-reason for unverified-channel denials. */
18
- export type DenialReason = 'no_binding' | 'no_identity';
19
- export type ActorRole = 'guardian' | 'non-guardian' | 'unverified_channel';
18
+ /** Trust classification used by route-level channel logic. */
19
+ export type ActorTrustClass = TrustClass;
20
20
 
21
21
  /** Guardian actor context used by route-level approval logic. */
22
22
  export interface GuardianContext {
23
- actorRole: ActorRole;
23
+ trustClass: ActorTrustClass;
24
24
  guardianChatId?: string;
25
25
  guardianExternalUserId?: string;
26
26
  requesterIdentifier?: string;
27
+ requesterDisplayName?: string;
28
+ requesterSenderDisplayName?: string;
29
+ requesterMemberDisplayName?: string;
27
30
  requesterExternalUserId?: string;
28
31
  requesterChatId?: string;
32
+ memberStatus?: string;
33
+ memberPolicy?: string;
29
34
  denialReason?: DenialReason;
30
35
  }
31
36
 
32
- export interface ResolveGuardianContextInput {
33
- assistantId: string;
34
- sourceChannel: ChannelId;
35
- externalChatId: string;
36
- senderExternalUserId?: string;
37
- senderUsername?: string;
38
- }
37
+ export type ResolveGuardianContextInput = ResolveActorTrustInput;
39
38
 
40
39
  /**
41
- * Resolve guardian actor role from canonical binding state + sender identity.
42
- *
43
- * Behavior:
44
- * - sender matches active binding -> guardian
45
- * - active binding exists but sender differs -> non-guardian
46
- * - no sender identity -> unverified_channel (no_identity)
47
- * - no binding -> unverified_channel (no_binding)
48
- *
49
- * Identity comparison is normalization-safe: both the sender ID and the
50
- * guardian binding ID are canonicalized for the source channel before
51
- * comparison, so formatting differences (e.g. `+1 (555) 123-4567` vs
52
- * `+15551234567`) do not cause false non-guardian classifications.
40
+ * Resolve route-level trust context from canonical identity state.
53
41
  */
54
42
  export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
55
- const assistantId = normalizeAssistantId(input.assistantId);
56
- const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
57
- ? input.senderExternalUserId.trim()
58
- : undefined;
59
- const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
60
- ? input.senderUsername.trim()
61
- : undefined;
62
-
63
- // Canonicalize sender identity for normalization-safe comparisons.
64
- // canonicalizeInboundIdentity returns string | null; coerce to
65
- // string | undefined so assignments to optional (string | undefined)
66
- // fields in GuardianContext don't produce a type mismatch.
67
- const canonicalSenderId = rawUserId
68
- ? (canonicalizeInboundIdentity(input.sourceChannel, rawUserId) ?? undefined)
69
- : undefined;
70
-
71
- const requesterIdentifier = senderUsername ? `@${senderUsername}` : canonicalSenderId;
72
-
73
- if (!canonicalSenderId) {
74
- return {
75
- actorRole: 'unverified_channel',
76
- denialReason: 'no_identity',
77
- requesterIdentifier,
78
- requesterExternalUserId: undefined,
79
- requesterChatId: input.externalChatId,
80
- };
81
- }
82
-
83
- const binding = getGuardianBinding(assistantId, input.sourceChannel);
84
- if (!binding) {
85
- return {
86
- actorRole: 'unverified_channel',
87
- denialReason: 'no_binding',
88
- requesterIdentifier,
89
- requesterExternalUserId: canonicalSenderId,
90
- requesterChatId: input.externalChatId,
91
- };
92
- }
93
-
94
- // Canonicalize the stored guardian identity for the same channel so
95
- // phone-format variance in the binding record doesn't cause mismatches.
96
- const canonicalGuardianId = canonicalizeInboundIdentity(
97
- input.sourceChannel,
98
- binding.guardianExternalUserId,
99
- ) ?? undefined;
100
-
101
- if (canonicalGuardianId === canonicalSenderId) {
102
- return {
103
- actorRole: 'guardian',
104
- guardianChatId: binding.guardianDeliveryChatId || input.externalChatId,
105
- guardianExternalUserId: binding.guardianExternalUserId,
106
- requesterIdentifier,
107
- requesterExternalUserId: canonicalSenderId,
108
- requesterChatId: input.externalChatId,
109
- };
110
- }
111
-
43
+ const trust = resolveActorTrust(input);
112
44
  return {
113
- actorRole: 'non-guardian',
114
- guardianChatId: binding.guardianDeliveryChatId,
115
- guardianExternalUserId: binding.guardianExternalUserId,
116
- requesterIdentifier,
117
- requesterExternalUserId: canonicalSenderId,
45
+ trustClass: trust.trustClass,
46
+ guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
47
+ (trust.trustClass === 'guardian' ? input.externalChatId : undefined),
48
+ guardianExternalUserId: trust.guardianBindingMatch?.guardianExternalUserId,
49
+ requesterIdentifier: trust.actorMetadata.identifier,
50
+ requesterDisplayName: trust.actorMetadata.displayName,
51
+ requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
52
+ requesterMemberDisplayName: trust.actorMetadata.memberDisplayName,
53
+ requesterExternalUserId: trust.canonicalSenderId ?? undefined,
118
54
  requesterChatId: input.externalChatId,
55
+ memberStatus: trust.memberRecord?.status ?? undefined,
56
+ memberPolicy: trust.memberRecord?.policy ?? undefined,
57
+ denialReason: trust.denialReason,
119
58
  };
120
59
  }
121
60
 
122
61
  export function toGuardianRuntimeContext(sourceChannel: ChannelId, ctx: GuardianContext): GuardianRuntimeContext {
123
62
  return {
124
63
  sourceChannel,
125
- actorRole: ctx.actorRole,
64
+ trustClass: ctx.trustClass,
126
65
  guardianChatId: ctx.guardianChatId,
127
66
  guardianExternalUserId: ctx.guardianExternalUserId,
128
67
  requesterIdentifier: ctx.requesterIdentifier,
68
+ requesterDisplayName: ctx.requesterDisplayName,
69
+ requesterSenderDisplayName: ctx.requesterSenderDisplayName,
70
+ requesterMemberDisplayName: ctx.requesterMemberDisplayName,
129
71
  requesterExternalUserId: ctx.requesterExternalUserId,
130
72
  requesterChatId: ctx.requesterChatId,
131
73
  denialReason: ctx.denialReason,