@vellumai/assistant 0.3.19 → 0.3.20

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 (189) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  7. package/src/__tests__/approval-primitive.test.ts +540 -0
  8. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  9. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  10. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  11. package/src/__tests__/call-controller.test.ts +439 -108
  12. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  13. package/src/__tests__/cli.test.ts +42 -1
  14. package/src/__tests__/config-schema.test.ts +11 -127
  15. package/src/__tests__/config-watcher.test.ts +0 -8
  16. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  17. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  18. package/src/__tests__/diff.test.ts +22 -0
  19. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  20. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  21. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  22. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  23. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  24. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  25. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  26. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  27. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  28. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  29. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  30. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  31. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  32. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  33. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  34. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  35. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  36. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  37. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  38. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  39. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  40. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  41. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  42. package/src/__tests__/system-prompt.test.ts +1 -1
  43. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  44. package/src/__tests__/terminal-tools.test.ts +2 -93
  45. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  46. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  47. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  48. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  49. package/src/agent/loop.ts +36 -1
  50. package/src/approvals/approval-primitive.ts +381 -0
  51. package/src/approvals/guardian-decision-primitive.ts +191 -0
  52. package/src/calls/call-controller.ts +252 -209
  53. package/src/calls/call-domain.ts +44 -6
  54. package/src/calls/guardian-dispatch.ts +48 -0
  55. package/src/calls/types.ts +1 -1
  56. package/src/calls/voice-session-bridge.ts +46 -30
  57. package/src/cli/core-commands.ts +0 -4
  58. package/src/cli.ts +76 -34
  59. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  60. package/src/config/assistant-feature-flags.ts +162 -0
  61. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  62. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  63. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  64. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  65. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  66. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  67. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  68. package/src/config/core-schema.ts +1 -1
  69. package/src/config/env-registry.ts +10 -0
  70. package/src/config/feature-flag-registry.json +61 -0
  71. package/src/config/loader.ts +22 -1
  72. package/src/config/sandbox-schema.ts +0 -39
  73. package/src/config/schema.ts +6 -2
  74. package/src/config/skill-state.ts +34 -0
  75. package/src/config/skills-schema.ts +0 -1
  76. package/src/config/skills.ts +9 -0
  77. package/src/config/system-prompt.ts +110 -46
  78. package/src/config/templates/SOUL.md +1 -1
  79. package/src/config/types.ts +19 -1
  80. package/src/config/vellum-skills/catalog.json +1 -1
  81. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  82. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  83. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  84. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  86. package/src/daemon/config-watcher.ts +0 -1
  87. package/src/daemon/daemon-control.ts +1 -1
  88. package/src/daemon/guardian-invite-intent.ts +124 -0
  89. package/src/daemon/handlers/avatar.ts +68 -0
  90. package/src/daemon/handlers/browser.ts +2 -2
  91. package/src/daemon/handlers/guardian-actions.ts +120 -0
  92. package/src/daemon/handlers/index.ts +4 -0
  93. package/src/daemon/handlers/sessions.ts +19 -0
  94. package/src/daemon/handlers/shared.ts +3 -1
  95. package/src/daemon/install-cli-launchers.ts +58 -13
  96. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  97. package/src/daemon/ipc-contract/sessions.ts +8 -2
  98. package/src/daemon/ipc-contract/settings.ts +25 -2
  99. package/src/daemon/ipc-contract-inventory.json +10 -0
  100. package/src/daemon/ipc-contract.ts +4 -0
  101. package/src/daemon/lifecycle.ts +6 -2
  102. package/src/daemon/main.ts +1 -0
  103. package/src/daemon/server.ts +1 -0
  104. package/src/daemon/session-lifecycle.ts +52 -7
  105. package/src/daemon/session-memory.ts +45 -0
  106. package/src/daemon/session-process.ts +258 -432
  107. package/src/daemon/session-runtime-assembly.ts +12 -0
  108. package/src/daemon/session-skill-tools.ts +14 -1
  109. package/src/daemon/session-tool-setup.ts +5 -0
  110. package/src/daemon/session.ts +11 -0
  111. package/src/daemon/tool-side-effects.ts +35 -9
  112. package/src/index.ts +0 -2
  113. package/src/memory/conversation-display-order-migration.ts +44 -0
  114. package/src/memory/conversation-queries.ts +2 -0
  115. package/src/memory/conversation-store.ts +91 -0
  116. package/src/memory/db-init.ts +5 -1
  117. package/src/memory/embedding-local.ts +13 -8
  118. package/src/memory/guardian-action-store.ts +125 -2
  119. package/src/memory/ingress-invite-store.ts +95 -1
  120. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  121. package/src/memory/migrations/index.ts +2 -1
  122. package/src/memory/schema.ts +5 -1
  123. package/src/memory/scoped-approval-grants.ts +14 -5
  124. package/src/messaging/providers/slack/client.ts +12 -0
  125. package/src/messaging/providers/slack/types.ts +5 -0
  126. package/src/notifications/decision-engine.ts +49 -12
  127. package/src/notifications/emit-signal.ts +7 -0
  128. package/src/notifications/signal.ts +7 -0
  129. package/src/notifications/thread-seed-composer.ts +2 -1
  130. package/src/runtime/channel-approval-types.ts +16 -6
  131. package/src/runtime/channel-approvals.ts +19 -15
  132. package/src/runtime/channel-invite-transport.ts +85 -0
  133. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  134. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  135. package/src/runtime/guardian-action-message-composer.ts +30 -0
  136. package/src/runtime/guardian-decision-types.ts +91 -0
  137. package/src/runtime/http-server.ts +23 -1
  138. package/src/runtime/ingress-service.ts +22 -0
  139. package/src/runtime/invite-redemption-service.ts +181 -0
  140. package/src/runtime/invite-redemption-templates.ts +39 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  143. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  144. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  145. package/src/runtime/routes/pairing-routes.ts +4 -0
  146. package/src/security/encrypted-store.ts +31 -17
  147. package/src/security/keychain.ts +176 -2
  148. package/src/security/secure-keys.ts +97 -0
  149. package/src/security/tool-approval-digest.ts +1 -1
  150. package/src/tools/browser/browser-execution.ts +2 -2
  151. package/src/tools/browser/browser-manager.ts +46 -32
  152. package/src/tools/browser/browser-screencast.ts +2 -2
  153. package/src/tools/calls/call-start.ts +1 -1
  154. package/src/tools/executor.ts +22 -17
  155. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  156. package/src/tools/skills/load.ts +22 -8
  157. package/src/tools/system/avatar-generator.ts +119 -0
  158. package/src/tools/system/navigate-settings.ts +65 -0
  159. package/src/tools/system/open-system-settings.ts +75 -0
  160. package/src/tools/system/voice-config.ts +121 -32
  161. package/src/tools/terminal/backends/native.ts +40 -19
  162. package/src/tools/terminal/backends/types.ts +3 -3
  163. package/src/tools/terminal/parser.ts +1 -1
  164. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  165. package/src/tools/terminal/sandbox.ts +1 -12
  166. package/src/tools/terminal/shell.ts +3 -31
  167. package/src/tools/tool-approval-handler.ts +141 -3
  168. package/src/tools/tool-manifest.ts +6 -0
  169. package/src/tools/types.ts +6 -0
  170. package/src/util/diff.ts +36 -13
  171. package/Dockerfile.sandbox +0 -5
  172. package/src/__tests__/doordash-client.test.ts +0 -187
  173. package/src/__tests__/doordash-session.test.ts +0 -154
  174. package/src/__tests__/signup-e2e.test.ts +0 -354
  175. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  176. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  177. package/src/cli/doordash.ts +0 -1057
  178. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  179. package/src/config/templates/LOOKS.md +0 -25
  180. package/src/doordash/cart-queries.ts +0 -787
  181. package/src/doordash/client.ts +0 -1016
  182. package/src/doordash/order-queries.ts +0 -85
  183. package/src/doordash/queries.ts +0 -13
  184. package/src/doordash/query-extractor.ts +0 -94
  185. package/src/doordash/search-queries.ts +0 -203
  186. package/src/doordash/session.ts +0 -84
  187. package/src/doordash/store-queries.ts +0 -246
  188. package/src/doordash/types.ts +0 -367
  189. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -2,6 +2,7 @@
2
2
  * Approval interception: checks for pending approvals and handles inbound
3
3
  * messages as decisions, reminders, or conversational follow-ups.
4
4
  */
5
+ import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
5
6
  import type { ChannelId } from '../../channels/types.js';
6
7
  import {
7
8
  getAllPendingApprovalsByGuardianChat,
@@ -11,9 +12,7 @@ import {
11
12
  type GuardianApprovalRequest,
12
13
  updateApprovalDecision,
13
14
  } from '../../memory/channel-guardian-store.js';
14
- import { createScopedApprovalGrant } from '../../memory/scoped-approval-grants.js';
15
15
  import { emitNotificationSignal } from '../../notifications/emit-signal.js';
16
- import { computeToolApprovalDigest } from '../../security/tool-approval-digest.js';
17
16
  import { getLogger } from '../../util/logger.js';
18
17
  import { runApprovalConversationTurn } from '../approval-conversation-turn.js';
19
18
  import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
@@ -25,7 +24,6 @@ import {
25
24
  getApprovalInfoByConversation,
26
25
  getChannelApprovalPrompt,
27
26
  handleChannelDecision,
28
- type PendingApprovalInfo,
29
27
  } from '../channel-approvals.js';
30
28
  import { deliverChannelReply } from '../gateway-client.js';
31
29
  import type {
@@ -49,68 +47,6 @@ import {
49
47
 
50
48
  const log = getLogger('runtime-http');
51
49
 
52
- /** TTL for scoped approval grants minted on guardian approve_once decisions. */
53
- export const GRANT_TTL_MS = 5 * 60 * 1000;
54
-
55
- // ---------------------------------------------------------------------------
56
- // Scoped grant minting on guardian tool-approval decisions
57
- // ---------------------------------------------------------------------------
58
-
59
- /**
60
- * Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
61
- * request. Only mints when the approval info contains a tool invocation with
62
- * input (so we can compute the input digest). Informational ASK_GUARDIAN
63
- * requests that lack tool input are skipped.
64
- *
65
- * Fails silently on error — grant minting is best-effort and must never block
66
- * the approval flow.
67
- */
68
- function tryMintToolApprovalGrant(params: {
69
- approvalInfo: PendingApprovalInfo;
70
- approval: GuardianApprovalRequest;
71
- decisionChannel: ChannelId;
72
- guardianExternalUserId: string;
73
- }): void {
74
- const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
75
-
76
- // Only mint for requests that carry a tool name — the presence of toolName
77
- // distinguishes tool-approval requests from informational ones.
78
- // computeToolApprovalDigest can deterministically hash {} so zero-argument
79
- // tool invocations must still receive a grant.
80
- if (!approvalInfo.toolName) {
81
- return;
82
- }
83
-
84
- try {
85
- const inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
86
-
87
- createScopedApprovalGrant({
88
- assistantId: approval.assistantId,
89
- scopeMode: 'tool_signature',
90
- toolName: approvalInfo.toolName,
91
- inputDigest,
92
- requestChannel: approval.channel,
93
- decisionChannel,
94
- executionChannel: null,
95
- conversationId: approval.conversationId,
96
- callSessionId: null,
97
- guardianExternalUserId,
98
- requesterExternalUserId: approval.requesterExternalUserId,
99
- expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
100
- });
101
-
102
- log.info(
103
- { toolName: approvalInfo.toolName, conversationId: approval.conversationId },
104
- 'Minted scoped approval grant for guardian tool-approval decision',
105
- );
106
- } catch (err) {
107
- log.error(
108
- { err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
109
- 'Failed to mint scoped approval grant (non-fatal)',
110
- );
111
- }
112
- }
113
-
114
50
  export interface ApprovalInterceptionParams {
115
51
  conversationId: string;
116
52
  callbackData?: string;
@@ -250,13 +186,6 @@ export async function handleApprovalInterception(
250
186
  }
251
187
 
252
188
  if (callbackDecision) {
253
- // approve_always is not available for guardian approvals — guardians
254
- // should not be able to permanently allowlist tools on behalf of the
255
- // requester. Downgrade to approve_once.
256
- if (callbackDecision.action === 'approve_always') {
257
- callbackDecision = { ...callbackDecision, action: 'approve_once' };
258
- }
259
-
260
189
  // Access request approvals don't have a pending interaction in the
261
190
  // session tracker, so they need a separate decision path that creates
262
191
  // a verification session instead of resuming an agent loop.
@@ -272,44 +201,22 @@ export async function handleApprovalInterception(
272
201
  return accessResult;
273
202
  }
274
203
 
275
- // Capture pending approval info before handleChannelDecision resolves
276
- // (and removes) the pending interaction. Needed for grant minting.
277
- const cbApprovalInfo = getApprovalInfoByConversation(guardianApproval.conversationId);
278
- const cbMatchedInfo = callbackDecision.requestId
279
- ? cbApprovalInfo.find(a => a.requestId === callbackDecision!.requestId)
280
- : cbApprovalInfo[0];
281
-
282
- // Apply the decision to the underlying session using the requester's
283
- // conversation context
284
- const result = handleChannelDecision(
285
- guardianApproval.conversationId,
286
- callbackDecision,
287
- );
204
+ // Apply the decision through the unified guardian decision primitive.
205
+ // The primitive handles approve_always downgrade, approval info capture,
206
+ // record update, and scoped grant minting.
207
+ const result = applyGuardianDecision({
208
+ approval: guardianApproval,
209
+ decision: callbackDecision,
210
+ actorExternalUserId: senderExternalUserId,
211
+ actorChannel: sourceChannel,
212
+ });
288
213
 
289
214
  if (result.applied) {
290
- // Update the guardian approval request record only when the decision
291
- // was actually applied. If the request was already resolved (race with
292
- // expiry sweep or concurrent callback), skip to avoid inconsistency.
293
- const approvalStatus = callbackDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
294
- updateApprovalDecision(guardianApproval.id, {
295
- status: approvalStatus,
296
- decidedByExternalUserId: senderExternalUserId,
297
- });
298
-
299
- // Mint a scoped grant when a guardian approves a tool-approval request
300
- if (callbackDecision.action !== 'reject' && cbMatchedInfo) {
301
- tryMintToolApprovalGrant({
302
- approvalInfo: cbMatchedInfo,
303
- approval: guardianApproval,
304
- decisionChannel: sourceChannel,
305
- guardianExternalUserId: senderExternalUserId,
306
- });
307
- }
308
-
309
215
  // Notify the requester's chat about the outcome with the tool name
216
+ const effectiveAction = callbackDecision.action === 'approve_always' ? 'approve_once' : callbackDecision.action;
310
217
  const outcomeText = await composeApprovalMessageGenerative({
311
218
  scenario: 'guardian_decision_outcome',
312
- decision: callbackDecision.action === 'reject' ? 'denied' : 'approved',
219
+ decision: effectiveAction === 'reject' ? 'denied' : 'approved',
313
220
  toolName: guardianApproval.toolName,
314
221
  channel: sourceChannel,
315
222
  }, {}, approvalCopyGenerator);
@@ -428,38 +335,15 @@ export async function handleApprovalInterception(
428
335
  ...(engineResult.targetRequestId ? { requestId: engineResult.targetRequestId } : {}),
429
336
  };
430
337
 
431
- // Capture pending approval info before handleChannelDecision resolves
432
- // (and removes) the pending interaction. Needed for grant minting.
433
- const engineApprovalInfo = getApprovalInfoByConversation(targetApproval.conversationId);
434
- const engineMatchedInfo = engineDecision.requestId
435
- ? engineApprovalInfo.find(a => a.requestId === engineDecision.requestId)
436
- : engineApprovalInfo[0];
437
-
438
- const result = handleChannelDecision(
439
- targetApproval.conversationId,
440
- engineDecision,
441
- );
338
+ // Apply the decision through the unified guardian decision primitive.
339
+ const result = applyGuardianDecision({
340
+ approval: targetApproval,
341
+ decision: engineDecision,
342
+ actorExternalUserId: senderExternalUserId,
343
+ actorChannel: sourceChannel,
344
+ });
442
345
 
443
346
  if (result.applied) {
444
- // Update the guardian approval request record only when the decision
445
- // was actually applied. If the request was already resolved (race with
446
- // expiry sweep or concurrent callback), skip to avoid inconsistency.
447
- const approvalStatus = decisionAction === 'reject' ? 'denied' as const : 'approved' as const;
448
- updateApprovalDecision(targetApproval.id, {
449
- status: approvalStatus,
450
- decidedByExternalUserId: senderExternalUserId,
451
- });
452
-
453
- // Mint a scoped grant when a guardian approves a tool-approval request
454
- if (decisionAction !== 'reject' && engineMatchedInfo) {
455
- tryMintToolApprovalGrant({
456
- approvalInfo: engineMatchedInfo,
457
- approval: targetApproval,
458
- decisionChannel: sourceChannel,
459
- guardianExternalUserId: senderExternalUserId,
460
- });
461
- }
462
-
463
347
  // Notify the requester's chat about the outcome
464
348
  const outcomeText = await composeApprovalMessageGenerative({
465
349
  scenario: 'guardian_decision_outcome',
@@ -591,35 +475,15 @@ export async function handleApprovalInterception(
591
475
  return accessResult;
592
476
  }
593
477
 
594
- // Capture pending approval info before handleChannelDecision resolves
595
- // (and removes) the pending interaction. Needed for grant minting.
596
- const legacyApprovalInfo = getApprovalInfoByConversation(targetLegacyApproval.conversationId);
597
- const legacyMatchedInfo = legacyGuardianDecision.requestId
598
- ? legacyApprovalInfo.find(a => a.requestId === legacyGuardianDecision.requestId)
599
- : legacyApprovalInfo[0];
600
-
601
- const result = handleChannelDecision(
602
- targetLegacyApproval.conversationId,
603
- legacyGuardianDecision,
604
- );
478
+ // Apply the decision through the unified guardian decision primitive.
479
+ const result = applyGuardianDecision({
480
+ approval: targetLegacyApproval,
481
+ decision: legacyGuardianDecision,
482
+ actorExternalUserId: senderExternalUserId,
483
+ actorChannel: sourceChannel,
484
+ });
605
485
 
606
486
  if (result.applied) {
607
- const approvalStatus = legacyGuardianDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
608
- updateApprovalDecision(targetLegacyApproval.id, {
609
- status: approvalStatus,
610
- decidedByExternalUserId: senderExternalUserId,
611
- });
612
-
613
- // Mint a scoped grant when a guardian approves a tool-approval request
614
- if (legacyGuardianDecision.action !== 'reject' && legacyMatchedInfo) {
615
- tryMintToolApprovalGrant({
616
- approvalInfo: legacyMatchedInfo,
617
- approval: targetLegacyApproval,
618
- decisionChannel: sourceChannel,
619
- guardianExternalUserId: senderExternalUserId,
620
- });
621
- }
622
-
623
487
  // Notify the requester's chat about the outcome
624
488
  const outcomeText = await composeApprovalMessageGenerative({
625
489
  scenario: 'guardian_decision_outcome',
@@ -742,13 +606,15 @@ export async function handleApprovalInterception(
742
606
  action: 'reject',
743
607
  source: 'plain_text',
744
608
  };
745
- const cancelApplyResult = handleChannelDecision(conversationId, rejectDecision);
609
+ // Apply the cancel decision through the unified primitive.
610
+ // The primitive handles record update and (no-op) grant logic.
611
+ const cancelApplyResult = applyGuardianDecision({
612
+ approval: guardianApprovalForRequest,
613
+ decision: rejectDecision,
614
+ actorExternalUserId: senderExternalUserId,
615
+ actorChannel: sourceChannel,
616
+ });
746
617
  if (cancelApplyResult.applied) {
747
- updateApprovalDecision(guardianApprovalForRequest.id, {
748
- status: 'denied',
749
- decidedByExternalUserId: senderExternalUserId,
750
- });
751
-
752
618
  // Notify requester
753
619
  const replyText = cancelReplyText ?? await composeApprovalMessageGenerative({
754
620
  scenario: 'requester_cancel',
@@ -1152,29 +1018,39 @@ async function handleAccessRequestApproval(
1152
1018
  });
1153
1019
  }
1154
1020
 
1155
- // Emit guardian_decision (approved) signal
1156
- void emitNotificationSignal({
1157
- sourceEventName: 'ingress.trusted_contact.guardian_decision',
1158
- sourceChannel: approval.channel,
1159
- sourceSessionId: approval.conversationId,
1160
- assistantId,
1161
- attentionHints: {
1162
- requiresAction: false,
1163
- urgency: 'medium',
1164
- isAsyncBackground: false,
1165
- visibleInSourceNow: false,
1166
- },
1167
- contextPayload: {
1021
+ // Don't emit guardian_decision for approvals that still require code
1022
+ // verification — the guardian already received the code, and emitting
1023
+ // this signal prematurely causes the notification pipeline to deliver
1024
+ // a confusing "approved" message before the requester has verified.
1025
+ // The guardian_decision signal should only fire once access is fully granted
1026
+ // (i.e. after code consumption), which is handled in the verification path.
1027
+ if (!decisionResult.verificationSessionId) {
1028
+ void emitNotificationSignal({
1029
+ sourceEventName: 'ingress.trusted_contact.guardian_decision',
1168
1030
  sourceChannel: approval.channel,
1169
- requesterExternalUserId: approval.requesterExternalUserId,
1170
- requesterChatId: approval.requesterChatId,
1171
- decidedByExternalUserId,
1172
- decision: 'approved',
1173
- },
1174
- dedupeKey: `trusted-contact:guardian-decision:${approval.id}`,
1175
- });
1031
+ sourceSessionId: approval.conversationId,
1032
+ assistantId,
1033
+ attentionHints: {
1034
+ requiresAction: false,
1035
+ urgency: 'medium',
1036
+ isAsyncBackground: false,
1037
+ visibleInSourceNow: false,
1038
+ },
1039
+ contextPayload: {
1040
+ sourceChannel: approval.channel,
1041
+ requesterExternalUserId: approval.requesterExternalUserId,
1042
+ requesterChatId: approval.requesterChatId,
1043
+ decidedByExternalUserId,
1044
+ decision: 'approved',
1045
+ },
1046
+ dedupeKey: `trusted-contact:guardian-decision:${approval.id}`,
1047
+ });
1048
+ }
1176
1049
 
1177
- // Only emit verification_sent when the code was actually delivered to the guardian.
1050
+ // Emit verification_sent with visibleInSourceNow=true so the notification
1051
+ // pipeline suppresses delivery — the guardian already received the
1052
+ // verification code directly. Without this flag, the pipeline generates
1053
+ // a redundant LLM message like "Good news! Your request has been approved."
1178
1054
  if (decisionResult.verificationSessionId && codeDelivered) {
1179
1055
  void emitNotificationSignal({
1180
1056
  sourceEventName: 'ingress.trusted_contact.verification_sent',
@@ -1185,7 +1061,7 @@ async function handleAccessRequestApproval(
1185
1061
  requiresAction: false,
1186
1062
  urgency: 'low',
1187
1063
  isAsyncBackground: true,
1188
- visibleInSourceNow: false,
1064
+ visibleInSourceNow: true,
1189
1065
  },
1190
1066
  contextPayload: {
1191
1067
  sourceChannel: approval.channel,