@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared access-request creation and notification helper.
3
+ *
4
+ * Encapsulates the "create/dedupe canonical access request + emit notification"
5
+ * logic so both text-channel and voice-channel ingress paths use identical
6
+ * guardian notification flows.
7
+ */
8
+
9
+ import type { ChannelId } from '../channels/types.js';
10
+ import {
11
+ createCanonicalGuardianRequest,
12
+ listCanonicalGuardianRequests,
13
+ } from '../memory/canonical-guardian-store.js';
14
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
15
+ import { getLogger } from '../util/logger.js';
16
+ import { getGuardianBinding } from './channel-guardian-service.js';
17
+ import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
18
+
19
+ const log = getLogger('access-request-helper');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface AccessRequestParams {
26
+ canonicalAssistantId: string;
27
+ sourceChannel: ChannelId;
28
+ externalChatId: string;
29
+ senderExternalUserId?: string;
30
+ senderName?: string;
31
+ senderUsername?: string;
32
+ }
33
+
34
+ export type AccessRequestResult =
35
+ | { notified: true; created: boolean; requestId: string }
36
+ | { notified: false; reason: 'no_sender_id' | 'no_guardian_binding' };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helper
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Create/dedupe a canonical access request and emit a notification signal
44
+ * so the guardian can approve or deny the unknown sender.
45
+ *
46
+ * Returns a result indicating whether the guardian was notified and whether
47
+ * a new request was created or an existing one was deduped.
48
+ *
49
+ * This is intentionally synchronous with respect to the canonical store writes
50
+ * and fire-and-forget for the notification signal emission.
51
+ */
52
+ export function notifyGuardianOfAccessRequest(
53
+ params: AccessRequestParams,
54
+ ): AccessRequestResult {
55
+ const {
56
+ canonicalAssistantId,
57
+ sourceChannel,
58
+ externalChatId,
59
+ senderExternalUserId,
60
+ senderName,
61
+ senderUsername,
62
+ } = params;
63
+
64
+ if (!senderExternalUserId) {
65
+ return { notified: false, reason: 'no_sender_id' };
66
+ }
67
+
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' };
72
+ }
73
+
74
+ // Deduplicate: skip creation if there is already a pending canonical request
75
+ // for the same requester on this channel. Still return notified: true with
76
+ // the existing request ID so callers know the guardian was already notified.
77
+ const existingCanonical = listCanonicalGuardianRequests({
78
+ status: 'pending',
79
+ requesterExternalUserId: senderExternalUserId,
80
+ sourceChannel,
81
+ kind: 'access_request',
82
+ });
83
+ if (existingCanonical.length > 0) {
84
+ log.debug(
85
+ { sourceChannel, senderExternalUserId, existingId: existingCanonical[0].id },
86
+ 'Skipping duplicate access request notification',
87
+ );
88
+ return { notified: true, created: false, requestId: existingCanonical[0].id };
89
+ }
90
+
91
+ const senderIdentifier = senderName || senderUsername || senderExternalUserId;
92
+ const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
93
+
94
+ const canonicalRequest = createCanonicalGuardianRequest({
95
+ id: requestId,
96
+ kind: 'access_request',
97
+ sourceType: 'channel',
98
+ sourceChannel,
99
+ conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
100
+ requesterExternalUserId: senderExternalUserId,
101
+ requesterChatId: externalChatId,
102
+ guardianExternalUserId: binding.guardianExternalUserId,
103
+ toolName: 'ingress_access_request',
104
+ questionText: `${senderIdentifier} is requesting access to the assistant`,
105
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
106
+ });
107
+
108
+ void emitNotificationSignal({
109
+ sourceEventName: 'ingress.access_request',
110
+ sourceChannel,
111
+ sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
112
+ assistantId: canonicalAssistantId,
113
+ attentionHints: {
114
+ requiresAction: true,
115
+ urgency: 'high',
116
+ isAsyncBackground: false,
117
+ visibleInSourceNow: false,
118
+ },
119
+ contextPayload: {
120
+ requestId,
121
+ sourceChannel,
122
+ externalChatId,
123
+ senderExternalUserId,
124
+ senderName: senderName ?? null,
125
+ senderUsername: senderUsername ?? null,
126
+ senderIdentifier,
127
+ },
128
+ dedupeKey: `access-request:${canonicalRequest.id}`,
129
+ });
130
+
131
+ log.info(
132
+ { sourceChannel, senderExternalUserId, senderIdentifier },
133
+ 'Guardian notified of access request',
134
+ );
135
+
136
+ return { notified: true, created: true, requestId: canonicalRequest.id };
137
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Unified inbound actor trust resolver.
3
+ *
4
+ * Produces a single trust-resolved actor context from raw inbound identity
5
+ * fields. Normalizes sender identity via channel-agnostic canonicalization,
6
+ * then resolves trust classification by checking guardian bindings and
7
+ * ingress member records.
8
+ *
9
+ * Trust classifications:
10
+ * - `guardian`: sender matches the active guardian binding for this channel.
11
+ * - `trusted_contact`: sender is an active ingress member (not the guardian).
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
+ */
18
+
19
+ import type { ChannelId } from '../channels/types.js';
20
+ import type { IngressMember } from '../memory/ingress-member-store.js';
21
+ import { findMember } from '../memory/ingress-member-store.js';
22
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
23
+ import { normalizeAssistantId } from '../util/platform.js';
24
+ import { getGuardianBinding } from './channel-guardian-service.js';
25
+ import type { ActorRole, DenialReason, GuardianContext } from './guardian-context-resolver.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type TrustClass = 'guardian' | 'trusted_contact' | 'unknown';
32
+
33
+ export interface ActorTrustContext {
34
+ /** Canonical (normalized) sender identity. Null when identity could not be established. */
35
+ canonicalSenderId: string | null;
36
+ /** Guardian binding match, if any, for this (assistantId, channel). */
37
+ guardianBindingMatch: {
38
+ guardianExternalUserId: string;
39
+ guardianDeliveryChatId: string | null;
40
+ } | null;
41
+ /** Ingress member record, if any, for this sender. */
42
+ memberRecord: IngressMember | null;
43
+ /** Trust classification. */
44
+ trustClass: TrustClass;
45
+ /** Assistant-facing metadata for downstream consumption. */
46
+ actorMetadata: {
47
+ identifier: string | undefined;
48
+ displayName: string | undefined;
49
+ username: string | undefined;
50
+ channel: ChannelId;
51
+ trustStatus: TrustClass;
52
+ };
53
+ /** Legacy denial reason for backward-compatible unverified_channel paths. */
54
+ denialReason?: DenialReason;
55
+ }
56
+
57
+ export interface ResolveActorTrustInput {
58
+ assistantId: string;
59
+ sourceChannel: ChannelId;
60
+ externalChatId: string;
61
+ senderExternalUserId?: string;
62
+ senderUsername?: string;
63
+ senderDisplayName?: string;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Resolver
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Resolve the inbound actor's trust context from raw identity fields.
72
+ *
73
+ * 1. Canonicalize the sender identity (E.164 for phone channels, trimmed ID otherwise).
74
+ * 2. Look up the guardian binding for (assistantId, channel).
75
+ * 3. Compare canonical sender identity to the guardian binding.
76
+ * 4. Look up the ingress member record using the canonical identity.
77
+ * 5. Classify: guardian > trusted_contact (active member) > unknown.
78
+ */
79
+ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
80
+ const assistantId = normalizeAssistantId(input.assistantId);
81
+
82
+ const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
83
+ ? input.senderExternalUserId.trim()
84
+ : undefined;
85
+
86
+ const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
87
+ ? input.senderUsername.trim()
88
+ : undefined;
89
+
90
+ const senderDisplayName = typeof input.senderDisplayName === 'string' && input.senderDisplayName.trim().length > 0
91
+ ? input.senderDisplayName.trim()
92
+ : undefined;
93
+
94
+ // Canonical identity: normalize phone-like channels to E.164.
95
+ const canonicalSenderId = rawUserId
96
+ ? canonicalizeInboundIdentity(input.sourceChannel, rawUserId)
97
+ : null;
98
+
99
+ const identifier = senderUsername ? `@${senderUsername}` : canonicalSenderId ?? undefined;
100
+
101
+ // No identity at all => unknown
102
+ if (!canonicalSenderId) {
103
+ return {
104
+ canonicalSenderId: null,
105
+ guardianBindingMatch: null,
106
+ memberRecord: null,
107
+ trustClass: 'unknown',
108
+ actorMetadata: {
109
+ identifier,
110
+ displayName: senderDisplayName,
111
+ username: senderUsername,
112
+ channel: input.sourceChannel,
113
+ trustStatus: 'unknown',
114
+ },
115
+ denialReason: 'no_identity',
116
+ };
117
+ }
118
+
119
+ // Guardian binding lookup
120
+ const binding = getGuardianBinding(assistantId, input.sourceChannel);
121
+ const guardianBindingMatch = binding
122
+ ? { guardianExternalUserId: binding.guardianExternalUserId, guardianDeliveryChatId: binding.guardianDeliveryChatId }
123
+ : null;
124
+
125
+ // Check if sender IS the guardian. Compare canonical sender against the
126
+ // binding's guardian identity (also canonicalize for phone channels to
127
+ // handle formatting variance in the stored binding).
128
+ let isGuardian = false;
129
+ if (binding) {
130
+ const canonicalGuardianId = canonicalizeInboundIdentity(input.sourceChannel, binding.guardianExternalUserId);
131
+ isGuardian = canonicalGuardianId === canonicalSenderId;
132
+ }
133
+
134
+ // Ingress member lookup using canonical identity.
135
+ const memberRecord = findMember({
136
+ assistantId,
137
+ sourceChannel: input.sourceChannel,
138
+ externalUserId: canonicalSenderId,
139
+ externalChatId: input.externalChatId,
140
+ });
141
+
142
+ // Trust classification
143
+ let trustClass: TrustClass;
144
+ if (isGuardian) {
145
+ trustClass = 'guardian';
146
+ } else if (memberRecord && memberRecord.status === 'active') {
147
+ trustClass = 'trusted_contact';
148
+ } else {
149
+ trustClass = 'unknown';
150
+ }
151
+
152
+ // Denial reason for legacy compatibility
153
+ let denialReason: DenialReason | undefined;
154
+ if (!isGuardian && !binding) {
155
+ denialReason = 'no_binding';
156
+ }
157
+
158
+ return {
159
+ canonicalSenderId,
160
+ guardianBindingMatch,
161
+ memberRecord,
162
+ trustClass,
163
+ actorMetadata: {
164
+ identifier,
165
+ displayName: senderDisplayName,
166
+ username: senderUsername,
167
+ channel: input.sourceChannel,
168
+ trustStatus: trustClass,
169
+ },
170
+ denialReason,
171
+ };
172
+ }
173
+
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
+ /**
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.
211
+ */
212
+ export function toGuardianContextCompat(ctx: ActorTrustContext, externalChatId: string): GuardianContext {
213
+ const actorRole = toLegacyActorRole(ctx);
214
+
215
+ return {
216
+ actorRole,
217
+ guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
218
+ (actorRole === 'guardian' ? externalChatId : undefined),
219
+ guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
220
+ requesterIdentifier: ctx.actorMetadata.identifier,
221
+ requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
222
+ requesterChatId: externalChatId,
223
+ denialReason: ctx.denialReason,
224
+ };
225
+ }
@@ -231,11 +231,19 @@ export function validateAndConsumeChallenge(
231
231
  }
232
232
  }
233
233
 
234
- // For Telegram: verify actorChatId matches expectedChatId
235
- // AND/OR actorExternalUserId matches expectedExternalUserId
234
+ // For chat-based channels (Telegram, Slack, etc.): when both
235
+ // expectedExternalUserId and expectedChatId are set, require the
236
+ // externalUserId match — chatId alone is insufficient because chat IDs
237
+ // can be shared (e.g. Slack channel IDs, Telegram group chat IDs) and
238
+ // would let any participant in the same chat satisfy identity binding.
239
+ // Fall back to chatId-only match only when expectedExternalUserId is
240
+ // not available (legacy sessions or channels without user-level identity).
236
241
  if (challenge.expectedChatId != null) {
237
- if (actorChatId === challenge.expectedChatId ||
238
- actorExternalUserId === challenge.expectedExternalUserId) {
242
+ if (challenge.expectedExternalUserId != null) {
243
+ if (actorExternalUserId === challenge.expectedExternalUserId) {
244
+ identityMatch = true;
245
+ }
246
+ } else if (actorChatId === challenge.expectedChatId) {
239
247
  identityMatch = true;
240
248
  }
241
249
  }
@@ -4,9 +4,13 @@
4
4
  * This module centralizes how we classify an inbound actor as
5
5
  * guardian/non-guardian/unverified so every channel path uses the same
6
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.
7
10
  */
8
11
  import type { ChannelId } from '../channels/types.js';
9
12
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
13
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
10
14
  import { normalizeAssistantId } from '../util/platform.js';
11
15
  import { getGuardianBinding } from './channel-guardian-service.js';
12
16
 
@@ -41,18 +45,32 @@ export interface ResolveGuardianContextInput {
41
45
  * - active binding exists but sender differs -> non-guardian
42
46
  * - no sender identity -> unverified_channel (no_identity)
43
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.
44
53
  */
45
54
  export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
46
55
  const assistantId = normalizeAssistantId(input.assistantId);
47
- const senderExternalUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
56
+ const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
48
57
  ? input.senderExternalUserId.trim()
49
58
  : undefined;
50
59
  const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
51
60
  ? input.senderUsername.trim()
52
61
  : undefined;
53
- const requesterIdentifier = senderUsername ? `@${senderUsername}` : senderExternalUserId;
54
62
 
55
- if (!senderExternalUserId) {
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) {
56
74
  return {
57
75
  actorRole: 'unverified_channel',
58
76
  denialReason: 'no_identity',
@@ -68,18 +86,25 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
68
86
  actorRole: 'unverified_channel',
69
87
  denialReason: 'no_binding',
70
88
  requesterIdentifier,
71
- requesterExternalUserId: senderExternalUserId,
89
+ requesterExternalUserId: canonicalSenderId,
72
90
  requesterChatId: input.externalChatId,
73
91
  };
74
92
  }
75
93
 
76
- if (binding.guardianExternalUserId === senderExternalUserId) {
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) {
77
102
  return {
78
103
  actorRole: 'guardian',
79
104
  guardianChatId: binding.guardianDeliveryChatId || input.externalChatId,
80
105
  guardianExternalUserId: binding.guardianExternalUserId,
81
106
  requesterIdentifier,
82
- requesterExternalUserId: senderExternalUserId,
107
+ requesterExternalUserId: canonicalSenderId,
83
108
  requesterChatId: input.externalChatId,
84
109
  };
85
110
  }
@@ -89,7 +114,7 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
89
114
  guardianChatId: binding.guardianDeliveryChatId,
90
115
  guardianExternalUserId: binding.guardianExternalUserId,
91
116
  requesterIdentifier,
92
- requesterExternalUserId: senderExternalUserId,
117
+ requesterExternalUserId: canonicalSenderId,
93
118
  requesterChatId: input.externalChatId,
94
119
  };
95
120
  }
@@ -22,6 +22,12 @@ export interface GuardianDecisionPrompt {
22
22
  expiresAt: number;
23
23
  conversationId: string;
24
24
  callSessionId: string | null;
25
+ /**
26
+ * Canonical request kind (e.g. 'tool_approval', 'pending_question').
27
+ * Present when the prompt originates from the canonical guardian request
28
+ * store. Absent for legacy-only prompts.
29
+ */
30
+ kind?: string;
25
31
  }
26
32
 
27
33
  export interface GuardianDecisionAction {