@vellumai/assistant 0.4.4 → 0.4.6

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 (90) hide show
  1. package/ARCHITECTURE.md +4 -4
  2. package/README.md +6 -6
  3. package/bun.lock +6 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/package.json +2 -2
  6. package/src/__tests__/actor-token-service.test.ts +5 -2
  7. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  8. package/src/__tests__/call-controller.test.ts +78 -0
  9. package/src/__tests__/call-domain.test.ts +148 -10
  10. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  11. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  12. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  13. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  14. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  15. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  16. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  17. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  18. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  19. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  20. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  21. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  22. package/src/__tests__/guardian-routing-state.test.ts +4 -4
  23. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  24. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  25. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  26. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  27. package/src/__tests__/non-member-access-request.test.ts +50 -47
  28. package/src/__tests__/relay-server.test.ts +71 -0
  29. package/src/__tests__/send-endpoint-busy.test.ts +6 -0
  30. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  31. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  32. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  33. package/src/__tests__/system-prompt.test.ts +1 -0
  34. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  35. package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
  39. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  40. package/src/approvals/guardian-decision-primitive.ts +29 -25
  41. package/src/approvals/guardian-request-resolvers.ts +9 -5
  42. package/src/calls/call-pointer-message-composer.ts +27 -85
  43. package/src/calls/call-pointer-messages.ts +54 -21
  44. package/src/calls/guardian-dispatch.ts +30 -0
  45. package/src/calls/relay-server.ts +13 -13
  46. package/src/config/system-prompt.ts +10 -3
  47. package/src/config/templates/BOOTSTRAP.md +6 -5
  48. package/src/config/templates/USER.md +1 -0
  49. package/src/config/user-reference.ts +44 -0
  50. package/src/daemon/handlers/guardian-actions.ts +5 -2
  51. package/src/daemon/handlers/sessions.ts +8 -3
  52. package/src/daemon/lifecycle.ts +109 -3
  53. package/src/daemon/server.ts +32 -24
  54. package/src/daemon/session-agent-loop.ts +4 -3
  55. package/src/daemon/session-lifecycle.ts +1 -9
  56. package/src/daemon/session-process.ts +2 -2
  57. package/src/daemon/session-runtime-assembly.ts +2 -0
  58. package/src/daemon/session-tool-setup.ts +10 -0
  59. package/src/daemon/session.ts +1 -0
  60. package/src/memory/canonical-guardian-store.ts +40 -0
  61. package/src/memory/conversation-crud.ts +26 -0
  62. package/src/memory/conversation-store.ts +1 -0
  63. package/src/memory/db-init.ts +8 -0
  64. package/src/memory/guardian-bindings.ts +4 -0
  65. package/src/memory/job-handlers/backfill.ts +2 -9
  66. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  67. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  68. package/src/memory/migrations/index.ts +2 -0
  69. package/src/memory/migrations/registry.ts +5 -0
  70. package/src/memory/schema.ts +3 -0
  71. package/src/notifications/copy-composer.ts +2 -2
  72. package/src/runtime/access-request-helper.ts +43 -28
  73. package/src/runtime/actor-trust-resolver.ts +19 -14
  74. package/src/runtime/channel-guardian-service.ts +6 -0
  75. package/src/runtime/guardian-context-resolver.ts +6 -2
  76. package/src/runtime/guardian-reply-router.ts +33 -16
  77. package/src/runtime/guardian-vellum-migration.ts +29 -5
  78. package/src/runtime/http-types.ts +0 -13
  79. package/src/runtime/local-actor-identity.ts +19 -13
  80. package/src/runtime/middleware/actor-token.ts +2 -2
  81. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  82. package/src/runtime/routes/conversation-routes.ts +45 -35
  83. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  84. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  85. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
  86. package/src/runtime/routes/inbound-conversation.ts +7 -7
  87. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  88. package/src/runtime/tool-grant-request-helper.ts +1 -0
  89. package/src/util/logger.ts +10 -0
  90. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -0,0 +1,210 @@
1
+ import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
2
+ import { withCrashRecovery } from './validate-migration-state.js';
3
+
4
+ /**
5
+ * Backfill guardianPrincipalId for existing channel_guardian_bindings and
6
+ * canonical_guardian_requests rows.
7
+ *
8
+ * Strategy:
9
+ *
10
+ * 1. Derive the assistant's canonical principal from the active 'vellum'
11
+ * binding's guardianExternalUserId. This is the stable identity used for
12
+ * all guardian decisions from the desktop client.
13
+ *
14
+ * 2. Backfill channel_guardian_bindings:
15
+ * a. For the vellum binding: set guardianPrincipalId = guardianExternalUserId
16
+ * (the vellum external user ID IS the canonical principal).
17
+ * b. For non-vellum bindings: set guardianPrincipalId to the vellum
18
+ * binding's principal (unifying all channels onto one canonical
19
+ * principal). Falls back to guardianExternalUserId if no vellum
20
+ * binding exists.
21
+ *
22
+ * 3. Backfill canonical_guardian_requests (pending only):
23
+ * a. If the request has a guardianExternalUserId that maps to an active
24
+ * binding, use that binding's guardianPrincipalId (now backfilled).
25
+ * b. For desktop-originated requests (sourceType = 'desktop' or
26
+ * sourceChannel = 'vellum') that lack guardianExternalUserId,
27
+ * use the assistant principal derived in step 1.
28
+ * c. Pending requests that cannot be deterministically bound are expired
29
+ * (including access_request rows, which are now principal-bound).
30
+ *
31
+ * 4. Idempotent: uses checkpoint key + only updates rows with NULL
32
+ * guardianPrincipalId.
33
+ */
34
+ export function migrateBackfillGuardianPrincipalId(database: DrizzleDb): void {
35
+ withCrashRecovery(database, 'migration_backfill_guardian_principal_id_v3', () => {
36
+ const raw = getSqliteFrom(database);
37
+
38
+ // Guard: tables must exist
39
+ const bindingsTableExists = raw.query(
40
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
41
+ ).get();
42
+ if (!bindingsTableExists) return;
43
+
44
+ const requestsTableExists = raw.query(
45
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'canonical_guardian_requests'`,
46
+ ).get();
47
+
48
+ // Guard: guardian_principal_id column must exist on bindings table
49
+ const bindingColExists = raw.query(
50
+ `SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
51
+ ).get();
52
+ if (!bindingColExists) return;
53
+
54
+ try {
55
+ raw.exec('BEGIN');
56
+
57
+ // ── Step 1a: Backfill vellum binding first ─────────────────────
58
+ // The vellum binding's external user ID IS the canonical principal.
59
+ raw.exec(/*sql*/ `
60
+ UPDATE channel_guardian_bindings
61
+ SET guardian_principal_id = guardian_external_user_id,
62
+ updated_at = ${Date.now()}
63
+ WHERE status = 'active'
64
+ AND channel = 'vellum'
65
+ AND guardian_external_user_id IS NOT NULL
66
+ AND guardian_principal_id IS NULL
67
+ `);
68
+
69
+ // ── Step 1b: Derive canonical principal from vellum binding ──
70
+ const vellumRow = raw.query(
71
+ `SELECT guardian_principal_id FROM channel_guardian_bindings
72
+ WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active'
73
+ AND guardian_principal_id IS NOT NULL LIMIT 1`,
74
+ ).get() as { guardian_principal_id: string } | null;
75
+
76
+ if (vellumRow) {
77
+ // Unify non-vellum bindings onto the canonical principal
78
+ raw.query(
79
+ `UPDATE channel_guardian_bindings
80
+ SET guardian_principal_id = ?,
81
+ updated_at = ${Date.now()}
82
+ WHERE status = 'active'
83
+ AND channel != 'vellum'
84
+ AND guardian_external_user_id IS NOT NULL
85
+ AND guardian_principal_id IS NULL`,
86
+ ).run(vellumRow.guardian_principal_id);
87
+ } else {
88
+ // No vellum binding — fallback to channel-specific principal
89
+ raw.exec(/*sql*/ `
90
+ UPDATE channel_guardian_bindings
91
+ SET guardian_principal_id = guardian_external_user_id,
92
+ updated_at = ${Date.now()}
93
+ WHERE status = 'active'
94
+ AND guardian_external_user_id IS NOT NULL
95
+ AND guardian_principal_id IS NULL
96
+ `);
97
+ }
98
+
99
+ // ── Step 2: Derive assistant principal from vellum binding ─────
100
+ // The vellum binding's guardianExternalUserId is the canonical
101
+ // assistant principal used for desktop-originated requests.
102
+ const vellumBinding = raw.query(
103
+ `SELECT guardian_external_user_id, guardian_principal_id FROM channel_guardian_bindings WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active' LIMIT 1`,
104
+ ).get() as { guardian_external_user_id: string; guardian_principal_id: string | null } | null;
105
+
106
+ // Use the (now-backfilled) principal from the vellum binding
107
+ const assistantPrincipal = vellumBinding?.guardian_principal_id ?? vellumBinding?.guardian_external_user_id ?? null;
108
+
109
+ // ── Step 3: Backfill canonical_guardian_requests ────────────────
110
+ if (requestsTableExists) {
111
+ const requestColExists = raw.query(
112
+ `SELECT 1 FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'guardian_principal_id'`,
113
+ ).get();
114
+
115
+ if (requestColExists) {
116
+ const now = new Date().toISOString();
117
+
118
+ // 3a. Pending requests with a guardianExternalUserId that maps
119
+ // to an active binding — use the binding's principal.
120
+ // Includes all kinds; binding is always useful when available.
121
+ const pendingWithGuardian = raw.query(
122
+ `SELECT r.id, r.guardian_external_user_id, r.kind
123
+ FROM canonical_guardian_requests r
124
+ WHERE r.status = 'pending'
125
+ AND r.guardian_principal_id IS NULL
126
+ AND r.guardian_external_user_id IS NOT NULL`,
127
+ ).all() as Array<{ id: string; guardian_external_user_id: string; kind: string }>;
128
+
129
+ // Build a lookup of guardianExternalUserId -> principalId from
130
+ // active bindings (all already backfilled in step 1).
131
+ const activeBindings = raw.query(
132
+ `SELECT guardian_external_user_id, guardian_principal_id
133
+ FROM channel_guardian_bindings
134
+ WHERE assistant_id = 'self' AND status = 'active' AND guardian_principal_id IS NOT NULL`,
135
+ ).all() as Array<{ guardian_external_user_id: string; guardian_principal_id: string }>;
136
+
137
+ const externalToP = new Map<string, string>();
138
+ for (const b of activeBindings) {
139
+ externalToP.set(b.guardian_external_user_id, b.guardian_principal_id);
140
+ }
141
+
142
+ const updateStmt = raw.prepare(
143
+ `UPDATE canonical_guardian_requests SET guardian_principal_id = ?, updated_at = ? WHERE id = ?`,
144
+ );
145
+ const expireStmt = raw.prepare(
146
+ `UPDATE canonical_guardian_requests SET status = 'expired', updated_at = ? WHERE id = ?`,
147
+ );
148
+
149
+ const unboundRequestIds: string[] = [];
150
+
151
+ for (const req of pendingWithGuardian) {
152
+ const principal = externalToP.get(req.guardian_external_user_id);
153
+ if (principal) {
154
+ updateStmt.run(principal, now, req.id);
155
+ } else {
156
+ // Cannot deterministically map — will expire below.
157
+ unboundRequestIds.push(req.id);
158
+ }
159
+ }
160
+
161
+ // 3b. Desktop-originated pending requests missing guardian info
162
+ // entirely — bind to the assistant principal. Only applies to
163
+ // requests that also lack a guardian external user ID; requests
164
+ // that carry an external ID but failed step 3a mapping should be
165
+ // expired, not reassigned.
166
+ if (assistantPrincipal) {
167
+ raw.query(
168
+ `UPDATE canonical_guardian_requests
169
+ SET guardian_principal_id = ?, updated_at = ?
170
+ WHERE status = 'pending'
171
+ AND guardian_principal_id IS NULL
172
+ AND guardian_external_user_id IS NULL
173
+ AND (source_type = 'desktop' OR source_channel = 'vellum')`,
174
+ ).run(assistantPrincipal, now);
175
+ }
176
+
177
+ // 3c. Expire remaining pending requests that still have no
178
+ // guardian_principal_id. These requests can never be approved
179
+ // in the principal-based system, so they must be expired
180
+ // proactively. This includes access_request rows which are
181
+ // now decisionable and principal-bound.
182
+ const stillUnbound = raw.query(
183
+ `SELECT id FROM canonical_guardian_requests
184
+ WHERE guardian_principal_id IS NULL
185
+ AND status = 'pending'`,
186
+ ).all() as Array<{ id: string }>;
187
+
188
+ for (const req of stillUnbound) {
189
+ expireStmt.run(now, req.id);
190
+ }
191
+
192
+ // Also expire requests identified in 3a that had no binding match.
193
+ for (const id of unboundRequestIds) {
194
+ const check = raw.query(
195
+ `SELECT guardian_principal_id FROM canonical_guardian_requests WHERE id = ? AND status = 'pending'`,
196
+ ).get(id) as { guardian_principal_id: string | null } | null;
197
+ if (check && !check.guardian_principal_id) {
198
+ expireStmt.run(now, id);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ raw.exec('COMMIT');
205
+ } catch (e) {
206
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
207
+ throw e;
208
+ }
209
+ });
210
+ }
@@ -65,6 +65,8 @@ export { createCanonicalGuardianTables } from './121-canonical-guardian-requests
65
65
  export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
66
66
  export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
67
67
  export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
68
+ export { migrateGuardianPrincipalIdColumns } from './125-guardian-principal-id-columns.js';
69
+ export { migrateBackfillGuardianPrincipalId } from './126-backfill-guardian-principal-id.js';
68
70
  export {
69
71
  MIGRATION_REGISTRY,
70
72
  type MigrationRegistryEntry,
@@ -95,6 +95,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
95
95
  version: 14,
96
96
  description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
97
97
  },
98
+ {
99
+ key: 'migration_backfill_guardian_principal_id_v2',
100
+ version: 15,
101
+ description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
102
+ },
98
103
  ];
99
104
 
100
105
  export interface MigrationValidationResult {
@@ -637,6 +637,7 @@ export const channelGuardianBindings = sqliteTable('channel_guardian_bindings',
637
637
  channel: text('channel').notNull(),
638
638
  guardianExternalUserId: text('guardian_external_user_id').notNull(),
639
639
  guardianDeliveryChatId: text('guardian_delivery_chat_id').notNull(),
640
+ guardianPrincipalId: text('guardian_principal_id'),
640
641
  status: text('status').notNull().default('active'),
641
642
  verifiedAt: integer('verified_at').notNull(),
642
643
  verifiedVia: text('verified_via').notNull().default('challenge'),
@@ -887,6 +888,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
887
888
  requesterExternalUserId: text('requester_external_user_id'),
888
889
  requesterChatId: text('requester_chat_id'),
889
890
  guardianExternalUserId: text('guardian_external_user_id'),
891
+ guardianPrincipalId: text('guardian_principal_id'),
890
892
  callSessionId: text('call_session_id'),
891
893
  pendingQuestionId: text('pending_question_id'),
892
894
  questionText: text('question_text'),
@@ -896,6 +898,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
896
898
  status: text('status').notNull().default('pending'),
897
899
  answerText: text('answer_text'),
898
900
  decidedByExternalUserId: text('decided_by_external_user_id'),
901
+ decidedByPrincipalId: text('decided_by_principal_id'),
899
902
  followupState: text('followup_state'),
900
903
  expiresAt: text('expires_at'),
901
904
  createdAt: text('created_at').notNull(),
@@ -64,7 +64,7 @@ const TEMPLATES: Record<string, CopyTemplate> = {
64
64
  const requester = str(payload.senderIdentifier, 'Someone');
65
65
  const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
66
66
  const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
67
- const callerName = nonEmpty(typeof payload.senderName === 'string' ? payload.senderName : undefined);
67
+ const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
68
68
  const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
69
69
  ? payload.previousMemberStatus
70
70
  : undefined;
@@ -72,7 +72,7 @@ const TEMPLATES: Record<string, CopyTemplate> = {
72
72
 
73
73
  // Voice-originated access requests include caller name context
74
74
  if (sourceChannel === 'voice' && callerName) {
75
- lines.push(`${callerName} (${str(payload.senderExternalUserId, requester)}) is calling and requesting access to the assistant.`);
75
+ lines.push(`${callerName} (${str(payload.actorExternalId, requester)}) is calling and requesting access to the assistant.`);
76
76
  } else {
77
77
  lines.push(`${requester} is requesting access to the assistant.`);
78
78
  }
@@ -26,6 +26,7 @@ import { emitNotificationSignal } from '../notifications/emit-signal.js';
26
26
  import type { NotificationDeliveryResult } from '../notifications/types.js';
27
27
  import { getLogger } from '../util/logger.js';
28
28
  import { getGuardianBinding } from './channel-guardian-service.js';
29
+ import { ensureVellumGuardianBinding } from './guardian-vellum-migration.js';
29
30
  import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
30
31
 
31
32
  const log = getLogger('access-request-helper');
@@ -45,10 +46,10 @@ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryRes
45
46
  export interface AccessRequestParams {
46
47
  canonicalAssistantId: string;
47
48
  sourceChannel: ChannelId;
48
- externalChatId: string;
49
- senderExternalUserId?: string;
50
- senderName?: string;
51
- senderUsername?: string;
49
+ conversationExternalId: string;
50
+ actorExternalId?: string;
51
+ actorDisplayName?: string;
52
+ actorUsername?: string;
52
53
  previousMemberStatus?: MemberStatus;
53
54
  }
54
55
 
@@ -80,14 +81,14 @@ export function notifyGuardianOfAccessRequest(
80
81
  const {
81
82
  canonicalAssistantId,
82
83
  sourceChannel,
83
- externalChatId,
84
- senderExternalUserId,
85
- senderName,
86
- senderUsername,
84
+ conversationExternalId,
85
+ actorExternalId,
86
+ actorDisplayName,
87
+ actorUsername,
87
88
  previousMemberStatus,
88
89
  } = params;
89
90
 
90
- if (!senderExternalUserId) {
91
+ if (!actorExternalId) {
91
92
  return { notified: false, reason: 'no_sender_id' };
92
93
  }
93
94
 
@@ -97,33 +98,46 @@ export function notifyGuardianOfAccessRequest(
97
98
  // 3. null (no guardian identity — notification pipeline uses trusted channels)
98
99
  const sourceBinding = getGuardianBinding(canonicalAssistantId, sourceChannel);
99
100
  let guardianExternalUserId: string | null = null;
101
+ let guardianPrincipalId: string | null = null;
100
102
  let guardianBindingChannel: string | null = null;
101
103
 
102
104
  if (sourceBinding) {
103
105
  guardianExternalUserId = sourceBinding.guardianExternalUserId;
106
+ guardianPrincipalId = sourceBinding.guardianPrincipalId;
104
107
  guardianBindingChannel = sourceBinding.channel;
105
108
  } else {
106
109
  const allBindings = listActiveBindingsByAssistant(canonicalAssistantId);
107
110
  if (allBindings.length > 0) {
108
111
  guardianExternalUserId = allBindings[0].guardianExternalUserId;
112
+ guardianPrincipalId = allBindings[0].guardianPrincipalId;
109
113
  guardianBindingChannel = allBindings[0].channel;
110
114
  log.debug(
111
115
  { sourceChannel, fallbackChannel: guardianBindingChannel, canonicalAssistantId },
112
116
  'Using cross-channel guardian binding fallback for access request',
113
117
  );
114
- } else {
115
- log.debug(
116
- { sourceChannel, canonicalAssistantId },
117
- 'No guardian binding for access request — proceeding without guardian identity',
118
- );
119
118
  }
120
119
  }
121
120
 
121
+ // Self-heal: access_request is now decisionable and requires a principal.
122
+ // If no binding was found (or the binding lacks a principal), bootstrap the
123
+ // vellum binding so the request can be properly attributed.
124
+ if (!guardianPrincipalId) {
125
+ log.info(
126
+ { sourceChannel, canonicalAssistantId },
127
+ 'No guardian principal for access request — self-healing vellum binding',
128
+ );
129
+ const healedPrincipalId = ensureVellumGuardianBinding(canonicalAssistantId);
130
+ const vellumBinding = getGuardianBinding(canonicalAssistantId, 'vellum');
131
+ guardianExternalUserId = vellumBinding?.guardianExternalUserId ?? guardianExternalUserId;
132
+ guardianPrincipalId = vellumBinding?.guardianPrincipalId ?? healedPrincipalId;
133
+ guardianBindingChannel = guardianBindingChannel ?? 'vellum';
134
+ }
135
+
122
136
  // The conversationId is assistant-scoped so the dedupe query below only
123
137
  // matches requests for the same assistant. Without this, a pending request
124
138
  // from assistant A could be returned for assistant B, allowing the caller
125
139
  // to piggyback on A's guardian approval.
126
- const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}`;
140
+ const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}`;
127
141
 
128
142
  // Deduplicate: skip creation if there is already a pending canonical request
129
143
  // for the same requester on this channel *and* assistant. Still return
@@ -131,21 +145,21 @@ export function notifyGuardianOfAccessRequest(
131
145
  // was already notified.
132
146
  const existingCanonical = listCanonicalGuardianRequests({
133
147
  status: 'pending',
134
- requesterExternalUserId: senderExternalUserId,
148
+ requesterExternalUserId: actorExternalId,
135
149
  sourceChannel,
136
150
  kind: 'access_request',
137
151
  conversationId,
138
152
  });
139
153
  if (existingCanonical.length > 0) {
140
154
  log.debug(
141
- { sourceChannel, senderExternalUserId, existingId: existingCanonical[0].id },
155
+ { sourceChannel, actorExternalId, existingId: existingCanonical[0].id },
142
156
  'Skipping duplicate access request notification',
143
157
  );
144
158
  return { notified: true, created: false, requestId: existingCanonical[0].id };
145
159
  }
146
160
 
147
- const senderIdentifier = senderName || senderUsername || senderExternalUserId;
148
- const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
161
+ const senderIdentifier = actorDisplayName || actorUsername || actorExternalId;
162
+ const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}-${Date.now()}`;
149
163
 
150
164
  const canonicalRequest = createCanonicalGuardianRequest({
151
165
  id: requestId,
@@ -153,9 +167,10 @@ export function notifyGuardianOfAccessRequest(
153
167
  sourceType: 'channel',
154
168
  sourceChannel,
155
169
  conversationId,
156
- requesterExternalUserId: senderExternalUserId,
157
- requesterChatId: externalChatId,
170
+ requesterExternalUserId: actorExternalId,
171
+ requesterChatId: conversationExternalId,
158
172
  guardianExternalUserId: guardianExternalUserId ?? undefined,
173
+ guardianPrincipalId: guardianPrincipalId ?? undefined,
159
174
  toolName: 'ingress_access_request',
160
175
  questionText: `${senderIdentifier} is requesting access to the assistant`,
161
176
  expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
@@ -165,7 +180,7 @@ export function notifyGuardianOfAccessRequest(
165
180
  void emitNotificationSignal({
166
181
  sourceEventName: 'ingress.access_request',
167
182
  sourceChannel,
168
- sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
183
+ sourceSessionId: `access-req-${sourceChannel}-${actorExternalId}`,
169
184
  assistantId: canonicalAssistantId,
170
185
  attentionHints: {
171
186
  requiresAction: true,
@@ -177,10 +192,10 @@ export function notifyGuardianOfAccessRequest(
177
192
  requestId,
178
193
  requestCode: canonicalRequest.requestCode,
179
194
  sourceChannel,
180
- externalChatId,
181
- senderExternalUserId,
182
- senderName: senderName ?? null,
183
- senderUsername: senderUsername ?? null,
195
+ conversationExternalId,
196
+ actorExternalId,
197
+ actorDisplayName: actorDisplayName ?? null,
198
+ actorUsername: actorUsername ?? null,
184
199
  senderIdentifier,
185
200
  guardianBindingChannel,
186
201
  previousMemberStatus: previousMemberStatus ?? null,
@@ -237,13 +252,13 @@ export function notifyGuardianOfAccessRequest(
237
252
  })
238
253
  .catch((err) => {
239
254
  log.error(
240
- { err, requestId: canonicalRequest.id, sourceChannel, senderExternalUserId },
255
+ { err, requestId: canonicalRequest.id, sourceChannel, actorExternalId },
241
256
  'Failed to persist access request delivery rows from notification pipeline',
242
257
  );
243
258
  });
244
259
 
245
260
  log.info(
246
- { sourceChannel, senderExternalUserId, senderIdentifier, guardianBindingChannel },
261
+ { sourceChannel, actorExternalId, senderIdentifier, guardianBindingChannel },
247
262
  'Guardian notified of access request',
248
263
  );
249
264
 
@@ -35,6 +35,8 @@ export interface ActorTrustContext {
35
35
  guardianExternalUserId: string;
36
36
  guardianDeliveryChatId: string | null;
37
37
  } | null;
38
+ /** Canonical principal ID from the guardian binding. Nullable for backward compatibility — M5 will make this required. */
39
+ guardianPrincipalId?: string | null;
38
40
  /** Ingress member record, if any, for this sender. */
39
41
  memberRecord: IngressMember | null;
40
42
  /** Trust classification. */
@@ -56,10 +58,10 @@ export interface ActorTrustContext {
56
58
  export interface ResolveActorTrustInput {
57
59
  assistantId: string;
58
60
  sourceChannel: ChannelId;
59
- externalChatId: string;
60
- senderExternalUserId?: string;
61
- senderUsername?: string;
62
- senderDisplayName?: string;
61
+ conversationExternalId: string;
62
+ actorExternalId?: string;
63
+ actorUsername?: string;
64
+ actorDisplayName?: string;
63
65
  }
64
66
 
65
67
  // ---------------------------------------------------------------------------
@@ -78,16 +80,16 @@ export interface ResolveActorTrustInput {
78
80
  export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
79
81
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
80
82
 
81
- const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
82
- ? input.senderExternalUserId.trim()
83
+ const rawUserId = typeof input.actorExternalId === 'string' && input.actorExternalId.trim().length > 0
84
+ ? input.actorExternalId.trim()
83
85
  : undefined;
84
86
 
85
- const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
86
- ? input.senderUsername.trim()
87
+ const senderUsername = typeof input.actorUsername === 'string' && input.actorUsername.trim().length > 0
88
+ ? input.actorUsername.trim()
87
89
  : undefined;
88
90
 
89
- const senderDisplayName = typeof input.senderDisplayName === 'string' && input.senderDisplayName.trim().length > 0
90
- ? input.senderDisplayName.trim()
91
+ const senderDisplayName = typeof input.actorDisplayName === 'string' && input.actorDisplayName.trim().length > 0
92
+ ? input.actorDisplayName.trim()
91
93
  : undefined;
92
94
 
93
95
  // Canonical identity: normalize phone-like channels to E.164.
@@ -102,6 +104,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
102
104
  return {
103
105
  canonicalSenderId: null,
104
106
  guardianBindingMatch: null,
107
+ guardianPrincipalId: undefined,
105
108
  memberRecord: null,
106
109
  trustClass: 'unknown',
107
110
  actorMetadata: {
@@ -137,7 +140,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
137
140
  assistantId,
138
141
  sourceChannel: input.sourceChannel,
139
142
  externalUserId: canonicalSenderId,
140
- externalChatId: input.externalChatId,
143
+ externalChatId: input.conversationExternalId,
141
144
  });
142
145
 
143
146
  // In group chats, findMember may match on externalChatId and return a
@@ -181,6 +184,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
181
184
  return {
182
185
  canonicalSenderId,
183
186
  guardianBindingMatch,
187
+ guardianPrincipalId: binding?.guardianPrincipalId ?? undefined,
184
188
  memberRecord,
185
189
  trustClass,
186
190
  actorMetadata: {
@@ -202,20 +206,21 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
202
206
  */
203
207
  export function toGuardianRuntimeContextFromTrust(
204
208
  ctx: ActorTrustContext,
205
- externalChatId: string,
209
+ conversationExternalId: string,
206
210
  ): GuardianRuntimeContext {
207
211
  return {
208
212
  sourceChannel: ctx.actorMetadata.channel,
209
213
  trustClass: ctx.trustClass,
210
214
  guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
211
- (ctx.trustClass === 'guardian' ? externalChatId : undefined),
215
+ (ctx.trustClass === 'guardian' ? conversationExternalId : undefined),
212
216
  guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
217
+ guardianPrincipalId: ctx.guardianPrincipalId,
213
218
  requesterIdentifier: ctx.actorMetadata.identifier,
214
219
  requesterDisplayName: ctx.actorMetadata.displayName,
215
220
  requesterSenderDisplayName: ctx.actorMetadata.senderDisplayName,
216
221
  requesterMemberDisplayName: ctx.actorMetadata.memberDisplayName,
217
222
  requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
218
- requesterChatId: externalChatId,
223
+ requesterChatId: conversationExternalId,
219
224
  denialReason: ctx.denialReason,
220
225
  };
221
226
  }
@@ -310,12 +310,18 @@ export function validateAndConsumeChallenge(
310
310
  metadata.displayName = actorDisplayName.trim();
311
311
  }
312
312
 
313
+ // Unify all channel bindings onto the canonical (vellum) principal so that
314
+ // cross-channel authorization reduces to strict principal equality.
315
+ const vellumBinding = getActiveBinding(assistantId, 'vellum');
316
+ const canonicalPrincipal = vellumBinding?.guardianPrincipalId ?? actorExternalUserId;
317
+
313
318
  // Create the new guardian binding
314
319
  const binding = createBinding({
315
320
  assistantId,
316
321
  channel,
317
322
  guardianExternalUserId: actorExternalUserId,
318
323
  guardianDeliveryChatId: actorChatId,
324
+ guardianPrincipalId: canonicalPrincipal,
319
325
  verifiedVia: 'challenge',
320
326
  metadataJson: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
321
327
  });
@@ -24,6 +24,8 @@ export interface GuardianContext {
24
24
  trustClass: ActorTrustClass;
25
25
  guardianChatId?: string;
26
26
  guardianExternalUserId?: string;
27
+ /** Canonical principal ID from the guardian binding. Nullable for backward compatibility — M5 will make this required. */
28
+ guardianPrincipalId?: string | null;
27
29
  requesterIdentifier?: string;
28
30
  requesterDisplayName?: string;
29
31
  requesterSenderDisplayName?: string;
@@ -48,14 +50,15 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
48
50
  return {
49
51
  trustClass: trust.trustClass,
50
52
  guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
51
- (trust.trustClass === 'guardian' ? input.externalChatId : undefined),
53
+ (trust.trustClass === 'guardian' ? input.conversationExternalId : undefined),
52
54
  guardianExternalUserId: canonicalGuardianExternalUserId,
55
+ guardianPrincipalId: trust.guardianPrincipalId,
53
56
  requesterIdentifier: trust.actorMetadata.identifier,
54
57
  requesterDisplayName: trust.actorMetadata.displayName,
55
58
  requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
56
59
  requesterMemberDisplayName: trust.actorMetadata.memberDisplayName,
57
60
  requesterExternalUserId: trust.canonicalSenderId ?? undefined,
58
- requesterChatId: input.externalChatId,
61
+ requesterChatId: input.conversationExternalId,
59
62
  memberStatus: trust.memberRecord?.status ?? undefined,
60
63
  memberPolicy: trust.memberRecord?.policy ?? undefined,
61
64
  denialReason: trust.denialReason,
@@ -150,6 +153,7 @@ export function toGuardianRuntimeContext(sourceChannel: ChannelId, ctx: Guardian
150
153
  trustClass: ctx.trustClass,
151
154
  guardianChatId: ctx.guardianChatId,
152
155
  guardianExternalUserId: ctx.guardianExternalUserId,
156
+ guardianPrincipalId: ctx.guardianPrincipalId,
153
157
  requesterIdentifier: ctx.requesterIdentifier,
154
158
  requesterDisplayName: ctx.requesterDisplayName,
155
159
  requesterSenderDisplayName: ctx.requesterSenderDisplayName,