@vellumai/assistant 0.12.4-dev.202609232047.847daa9 → 0.12.4-dev.202609232115.73e8b74

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.
@@ -94,11 +94,13 @@ sequenceDiagram
94
94
 
95
95
  The `enforceRoutingIntent()` step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:
96
96
 
97
- | Intent | Enforcement Rule |
98
- | ---------------- | ------------------------------------------------------------------------------------------------- |
99
- | `single_channel` | No override. The LLM's channel selection stands. |
100
- | `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
101
- | `all_channels` | Replace the LLM's selection with all connected channels. |
97
+ | Intent | Enforcement Rule |
98
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
99
+ | `single_channel` | Cap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. |
100
+ | `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
101
+ | `all_channels` | Replace the LLM's selection with all connected channels. |
102
+
103
+ A decision the LLM suppressed (`shouldNotify: false`) is left alone under every intent. A signal with no routing intent keeps the LLM's selection.
102
104
 
103
105
  When enforcement changes the decision, the updated `selectedChannels` and annotated `reasoningSummary` are re-persisted to `notification_decisions` so the audit trail reflects what was actually dispatched.
104
106
 
@@ -24,7 +24,7 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
24
24
  (generic actions[] + plainTextFallback) resolveQuestionContext)
25
25
  │
26
26
  ▼ per-channel rendering ONLY
27
- channel adapters (notifications/adapters/{telegram,slack,macos,platform},
27
+ channel adapters (notifications/adapters/{telegram,slack,discord,macos,platform},
28
28
  telegram: inline keyboard; slack: blocks; vellum: conversation card via approval-card-builder)
29
29
  card deliveries recorded per channel (guardian-delivery-recorder.ts → guardian_request_deliveries)
30
30
  │
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609232047.847daa9",
3
+ "version": "0.12.4-dev.202609232115.73e8b74",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -359,6 +359,68 @@ describe("access-request vellum floor", () => {
359
359
  );
360
360
  });
361
361
 
362
+ test("an all_channels access request the decision engine suppressed still fans out", async () => {
363
+ evaluateSignalMock.mockResolvedValue({
364
+ shouldNotify: false,
365
+ selectedChannels: [],
366
+ reasoningSummary: "LLM suppressed",
367
+ renderedCopy: {},
368
+ dedupeKey: "dedupe-ar-email",
369
+ confidence: 0.9,
370
+ fallbackUsed: false,
371
+ persistedDecisionId: "dec-ar-email",
372
+ });
373
+ // The real enforcement leaves a suppressed decision alone and replaces
374
+ // the selection with every connected channel otherwise.
375
+ enforceRoutingIntentMock.mockImplementation(
376
+ (
377
+ decision: { shouldNotify: boolean; selectedChannels: string[] },
378
+ intent: string | undefined,
379
+ connected: string[],
380
+ ) =>
381
+ intent === "all_channels" && decision.shouldNotify
382
+ ? { ...decision, selectedChannels: [...connected] }
383
+ : decision,
384
+ );
385
+
386
+ await emitNotificationSignal({
387
+ sourceEventName: "ingress.access_request",
388
+ sourceChannel: "email",
389
+ sourceContextId: "access-req-email-stranger",
390
+ requiresConversation: true,
391
+ routingIntent: "all_channels",
392
+ attentionHints: {
393
+ requiresAction: true,
394
+ urgency: "high",
395
+ isAsyncBackground: false,
396
+ visibleInSourceNow: false,
397
+ },
398
+ contextPayload: {
399
+ requestId: "req-email",
400
+ requestCode: "EM12CD",
401
+ sourceChannel: "email",
402
+ conversationExternalId: "<thread-1@example.com>",
403
+ actorExternalId: "stranger@example.com",
404
+ actorDisplayName: null,
405
+ actorUsername: null,
406
+ senderIdentifier: "stranger@example.com",
407
+ guardianBindingChannel: null,
408
+ guardianResolutionSource: "vellum-anchor",
409
+ previousMemberStatus: null,
410
+ messagePreview: null,
411
+ },
412
+ });
413
+
414
+ const enforced = enforceRoutingIntentMock.mock.calls[0][0] as {
415
+ shouldNotify: boolean;
416
+ };
417
+ expect(enforced.shouldNotify).toBe(true);
418
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
419
+ selectedChannels: string[];
420
+ };
421
+ expect(dispatched.selectedChannels).toContain("vellum");
422
+ });
423
+
362
424
  test("re-adds vellum when single_channel routing enforcement strips it", async () => {
363
425
  evaluateSignalMock.mockResolvedValue({
364
426
  shouldNotify: true,
@@ -218,6 +218,29 @@ describe("introduction card decisions", () => {
218
218
  }
219
219
  });
220
220
 
221
+ test("handshake approval on an email sender is coerced to direct trust", async () => {
222
+ for (const action of ["approve_once", "verify_code"] as const) {
223
+ resetState();
224
+ const req = makeAccessRequest({ sourceChannel: "email" });
225
+
226
+ const result = await applyGuardianDecision({
227
+ requestId: req.id,
228
+ action,
229
+ actorContext: desktopGuardian(),
230
+ });
231
+
232
+ expect(result.applied).toBe(true);
233
+ // No code is minted: email has no route to deliver one to the sender.
234
+ expect(outcomesOfType("mint_outbound_session")).toHaveLength(0);
235
+ const activations = outcomesOfType("activate_member");
236
+ expect(activations).toHaveLength(1);
237
+ expect(activations[0].sourceChannel).toBe("email");
238
+ expect(activations[0].verifiedVia).toBe("manual_channel_claim");
239
+ // The reported and projected outcome is the trust that was applied.
240
+ expect(result.applied && result.decidedAction).toBe("trust");
241
+ }
242
+ });
243
+
221
244
  test("leave_unverified persists the sender as unverified and is silent (legacy reject path)", async () => {
222
245
  for (const action of ["leave_unverified", "reject"] as const) {
223
246
  resetState();
@@ -392,18 +415,40 @@ describe("introduction card decisions", () => {
392
415
  });
393
416
 
394
417
  describe("introductionOutcomeForAction", () => {
418
+ const slackStranger = {
419
+ sourceChannel: "slack",
420
+ requesterSignals: serializeRequesterSignals({ isStranger: true }) ?? null,
421
+ };
422
+
395
423
  test("folds the generic decision pair onto the introduction outcomes", () => {
396
424
  // `reject` and `leave_unverified` both park the contact at `unverified`;
397
425
  // `approve_once` starts the handshake. Introduction actions map to
398
426
  // themselves. Callers that must present the resolved outcome (the card
399
427
  // projection) rely on this so a `reject` park never reads as "Denied".
400
- expect(introductionOutcomeForAction("reject")).toBe("leave_unverified");
401
- expect(introductionOutcomeForAction("leave_unverified")).toBe(
402
- "leave_unverified",
403
- );
404
- expect(introductionOutcomeForAction("approve_once")).toBe("verify_code");
405
- expect(introductionOutcomeForAction("verify_code")).toBe("verify_code");
406
- expect(introductionOutcomeForAction("trust")).toBe("trust");
407
- expect(introductionOutcomeForAction("block")).toBe("block");
428
+ const outcome = (
429
+ action: Parameters<typeof introductionOutcomeForAction>[1],
430
+ ) => introductionOutcomeForAction(slackStranger, action);
431
+ expect(outcome("reject")).toBe("leave_unverified");
432
+ expect(outcome("leave_unverified")).toBe("leave_unverified");
433
+ expect(outcome("approve_once")).toBe("verify_code");
434
+ expect(outcome("verify_code")).toBe("verify_code");
435
+ expect(outcome("trust")).toBe("trust");
436
+ expect(outcome("block")).toBe("block");
437
+ });
438
+
439
+ test("a handshake approval resolves to trust where the handshake cannot complete", () => {
440
+ const email = { sourceChannel: "email", requesterSignals: null };
441
+ const bot = {
442
+ sourceChannel: "slack",
443
+ requesterSignals: serializeRequesterSignals({ isBot: true }) ?? null,
444
+ };
445
+ for (const request of [email, bot]) {
446
+ expect(introductionOutcomeForAction(request, "approve_once")).toBe(
447
+ "trust",
448
+ );
449
+ expect(introductionOutcomeForAction(request, "verify_code")).toBe(
450
+ "trust",
451
+ );
452
+ }
408
453
  });
409
454
  });
@@ -598,6 +598,54 @@ describe("access-request-helper unit tests", () => {
598
598
  expect(payload.guardianBindingChannel).toBe("vellum");
599
599
  });
600
600
 
601
+ test("an email access request routes to every channel the guardian is connected on", async () => {
602
+ const result = await notifyGuardianOfAccessRequest({
603
+ sourceChannel: "email",
604
+ conversationExternalId: "<thread-1@example.com>",
605
+ actorExternalId: "stranger@example.com",
606
+ });
607
+
608
+ expect(result.notified).toBe(true);
609
+ expect(emitSignalCalls.length).toBe(1);
610
+ expect(emitSignalCalls[0].routingIntent).toBe("all_channels");
611
+ });
612
+
613
+ test("an admitted email sender's nudge keeps the default routing", async () => {
614
+ await maybeNotifyGuardianOfAdmittedContact({
615
+ sourceChannel: "email",
616
+ conversationExternalId: "<thread-2@example.com>",
617
+ actorExternalId: "admitted@example.com",
618
+ });
619
+
620
+ expect(emitSignalCalls.length).toBe(1);
621
+ expect(emitSignalCalls[0].routingIntent).toBeUndefined();
622
+ });
623
+
624
+ test("a telegram access request with a telegram guardian stays on telegram", async () => {
625
+ seedGatewayGuardian({
626
+ channelType: "telegram",
627
+ address: "guardian-tg",
628
+ externalChatId: "tg-chat",
629
+ principalId: anchorPrincipalId,
630
+ });
631
+ createGuardianBinding({
632
+ channel: "telegram",
633
+ guardianExternalUserId: "guardian-tg",
634
+ guardianDeliveryChatId: "tg-chat",
635
+ guardianPrincipalId: anchorPrincipalId,
636
+ verifiedVia: "test",
637
+ });
638
+
639
+ await notifyGuardianOfAccessRequest({
640
+ sourceChannel: "telegram",
641
+ conversationExternalId: "chat-123",
642
+ actorExternalId: "unknown-user",
643
+ });
644
+
645
+ expect(emitSignalCalls.length).toBe(1);
646
+ expect(emitSignalCalls[0].routingIntent).toBe("single_channel");
647
+ });
648
+
601
649
  test("notifyGuardianOfAccessRequest prefers source-channel binding over vellum anchor", async () => {
602
650
  // Both Telegram and voice bindings exist with the anchor principal
603
651
  seedGatewayGuardian({
@@ -1308,15 +1308,15 @@ describe("(g) access_request resolver: requester code delivery", () => {
1308
1308
  expect(verificationSent.length).toBe(1);
1309
1309
  });
1310
1310
 
1311
- test("off-channel approval on a channel with no deliverable callback (e.g. email) still records verification_sent", async () => {
1312
- // `email` has no deliver URL (resolveDeliverCallbackUrlForChannel returns
1311
+ test("off-channel approval on a channel with no deliverable callback (e.g. a plugin channel) still records verification_sent", async () => {
1312
+ // `plugin` has no deliver URL (resolveDeliverCallbackUrlForChannel returns
1313
1313
  // null), so the requester cannot be auto-notified here. The guardian still
1314
1314
  // receives the code inline, so the lifecycle transition must be recorded —
1315
1315
  // the emit must not be gated on requester deliverability.
1316
1316
  const req = createAccessRequest({
1317
- sourceChannel: "email",
1318
- requesterChatId: "requester@example.com",
1319
- sourceConversationId: "conv-access-email",
1317
+ sourceChannel: "plugin",
1318
+ requesterChatId: "plugin-requester-1",
1319
+ sourceConversationId: "conv-access-plugin",
1320
1320
  });
1321
1321
 
1322
1322
  const result = await applyGuardianDecision({
@@ -1335,7 +1335,7 @@ describe("(g) access_request resolver: requester code delivery", () => {
1335
1335
  const replyText = result.applied ? result.resolverReplyText : undefined;
1336
1336
  expect(replyText).toContain("123456");
1337
1337
  const requesterDelivery = deliveredReplies.find(
1338
- (r) => r.payload.chatId === "requester@example.com",
1338
+ (r) => r.payload.chatId === "plugin-requester-1",
1339
1339
  );
1340
1340
  expect(requesterDelivery).toBeUndefined();
1341
1341
 
@@ -529,12 +529,13 @@ export async function applyGuardianDecision(
529
529
  // never throws; the `.catch` is a defensive backstop.
530
530
  // For access requests the resolver folds the generic decision pair onto the
531
531
  // introduction outcomes (`reject` → `leave_unverified`, `approve_once` →
532
- // `verify_code`), so the resolved card must reflect the OUTCOME, not the raw
532
+ // `verify_code`, or `trust` for a requester who cannot complete a code
533
+ // handshake), so the resolved card must reflect the OUTCOME, not the raw
533
534
  // button — otherwise a `reject` that actually parked the contact at
534
535
  // `unverified` would still render "Denied". Other kinds have no such mapping.
535
536
  const cardAction =
536
537
  request.kind === "access_request"
537
- ? introductionOutcomeForAction(effectiveAction)
538
+ ? introductionOutcomeForAction(resolved, effectiveAction)
538
539
  : effectiveAction;
539
540
  void withdrawGuardianRequestCards({
540
541
  request: resolved,
@@ -46,6 +46,7 @@ import { deliverChannelReply } from "../runtime/gateway-client.js";
46
46
  import {
47
47
  introductionMode,
48
48
  parseRequesterSignals,
49
+ requesterCanCompleteHandshake,
49
50
  type RequesterIdentitySignals,
50
51
  resolveTrustBinding,
51
52
  } from "../runtime/introduction-policy.js";
@@ -619,20 +620,33 @@ const OUTCOME_BY_ACTION = {
619
620
  } as const satisfies Record<ApprovalAction, IntroductionOutcome>;
620
621
 
621
622
  /**
622
- * The introduction outcome a decision action resolves to for an access request.
623
- * The generic decision pair folds onto the card outcomes (`reject` →
624
- * `leave_unverified`, `approve_once` → `verify_code`); the introduction actions
625
- * map to themselves. Every outcome is itself an `ApprovalAction`, so a caller
626
- * that must reflect the resolved *outcome* rather than the raw button — the
627
- * resolved-card projection, so a `reject` that parked the contact at
628
- * `unverified` reads as the neutral "Left unverified" and not "Denied" — can
629
- * normalize through this. It does not apply the bot handshake→trust coercion,
630
- * which does not affect the park/deny distinction the card cares about.
623
+ * The introduction outcome a decision action resolves to for an access
624
+ * request. The generic decision pair folds onto the card outcomes (`reject` →
625
+ * `leave_unverified`, `approve_once` → `verify_code`) and the introduction
626
+ * actions map to themselves, except that a handshake approval on a requester
627
+ * who can never complete the handshake (a bot, an email sender) resolves to
628
+ * direct trust: the guardian's intent ("let them in") is unambiguous.
629
+ *
630
+ * The one derivation of the outcome: the resolver plans and follows through on
631
+ * it, and the decision primitive reports it (`decidedAction`) and projects it
632
+ * onto the resolved cards, so a `reject` that parked the contact reads as the
633
+ * neutral "Left unverified" and a coerced approval reads as the trust it was.
631
634
  */
632
635
  export function introductionOutcomeForAction(
636
+ request: Pick<GuardianRequestWire, "sourceChannel" | "requesterSignals">,
633
637
  action: ApprovalAction,
634
- ): ApprovalAction {
635
- return OUTCOME_BY_ACTION[action];
638
+ ): IntroductionOutcome {
639
+ const outcome = OUTCOME_BY_ACTION[action];
640
+ if (
641
+ outcome === "verify_code" &&
642
+ !requesterCanCompleteHandshake(
643
+ request.sourceChannel ?? undefined,
644
+ parseRequesterSignals(request.requesterSignals),
645
+ )
646
+ ) {
647
+ return "trust";
648
+ }
649
+ return outcome;
636
650
  }
637
651
 
638
652
  /** Derived access-request decision facts shared by `prepare` and `resolve`. */
@@ -675,15 +689,7 @@ function deriveAccessRequestDecision(
675
689
  requesterContactResult?.contact.displayName ?? null;
676
690
 
677
691
  const signals = parseRequesterSignals(request.requesterSignals);
678
- let outcome: IntroductionOutcome = OUTCOME_BY_ACTION[action];
679
-
680
- // A bot cannot return a verification code, so a handshake approval on a
681
- // bot requester can never complete. Coerce it to direct trust — the
682
- // guardian's intent ("let it in") is unambiguous. Logged once, in
683
- // `prepare` (this derivation runs again in `resolve`).
684
- if (outcome === "verify_code" && signals.isBot === true) {
685
- outcome = "trust";
686
- }
692
+ const outcome = introductionOutcomeForAction(request, action);
687
693
 
688
694
  return {
689
695
  channel,
@@ -885,8 +891,8 @@ async function notifyRequesterOfDenial(params: {
885
891
  * follow-through (requester/guardian notices, verification-code delivery
886
892
  * from the decide's `mintedSession`, lifecycle signals).
887
893
  *
888
- * A bot requester can never return a code, so handshake approvals are
889
- * coerced to direct trust.
894
+ * A requester who can never complete the handshake (a bot, an email
895
+ * sender) has handshake approvals coerced to direct trust.
890
896
  */
891
897
  const accessRequestResolver: GuardianRequestResolver = {
892
898
  kind: "access_request",
@@ -905,11 +911,12 @@ const accessRequestResolver: GuardianRequestResolver = {
905
911
  if (outcome !== OUTCOME_BY_ACTION[decision.action]) {
906
912
  log.info(
907
913
  {
908
- event: "resolver_access_request_bot_coercion",
914
+ event: "resolver_access_request_trust_coercion",
909
915
  requestId: request.id,
910
916
  action: decision.action,
917
+ channel,
911
918
  },
912
- "Access request resolver: handshake approval on a bot coerced to direct trust",
919
+ "Access request resolver: handshake approval coerced to direct trust (requester cannot complete a handshake)",
913
920
  );
914
921
  }
915
922
 
@@ -293,11 +293,11 @@ Schedules (both recurring and one-shot) carry optional routing metadata that con
293
293
 
294
294
  The `routing_intent` field on each `schedule_jobs` row specifies the desired channel coverage:
295
295
 
296
- | Intent | Behavior | When to use |
297
- | ---------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
298
- | `single_channel` | Default LLM-driven routing (no override) | Standard schedules where the decision engine picks the best channel |
299
- | `multi_channel` | Ensures delivery on 2+ channels when 2+ are connected | Important schedules the user wants on both desktop and phone |
300
- | `all_channels` | Forces delivery on every connected channel | Critical schedules that must reach the user everywhere |
296
+ | Intent | Behavior | When to use |
297
+ | ---------------- | ----------------------------------------------------- | ------------------------------------------------------------ |
298
+ | `single_channel` | Delivers on exactly one channel | Schedules that should reach the user in one place |
299
+ | `multi_channel` | Ensures delivery on 2+ channels when 2+ are connected | Important schedules the user wants on both desktop and phone |
300
+ | `all_channels` | Forces delivery on every connected channel | Critical schedules that must reach the user everywhere |
301
301
 
302
302
  The default is `all_channels`. Routing intent is persisted in the `schedule_jobs` table (`routing_intent` column) and carried through the notification signal as `routingIntent`.
303
303
 
@@ -325,7 +325,7 @@ The `enforceRoutingIntent()` function in `decision-engine.ts` runs after the LLM
325
325
 
326
326
  - **`all_channels`**: Replaces `selectedChannels` with all connected channels (from `getConnectedChannels()`).
327
327
  - **`multi_channel`**: If the LLM selected fewer than 2 channels but 2+ are connected, expands `selectedChannels` to at least two connected channels.
328
- - **`single_channel`**: No override -- the LLM's selection stands.
328
+ - **`single_channel`**: Caps delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. A signal with no routing intent keeps the LLM's selection.
329
329
 
330
330
  When enforcement changes the decision, the updated channel selection is re-persisted to the `notification_decisions` table so the stored decision matches what was actually dispatched. The `reasoningSummary` is annotated with the enforcement action (e.g. `[routing_intent=all_channels enforced: vellum, telegram]`).
331
331
 
@@ -512,7 +512,22 @@ export async function emitNotificationSignal<TEventName extends string>(
512
512
  }
513
513
 
514
514
  // Step 2.5b: Enforce routing intent policy (fire-time guard).
515
- // An exclusive allowlist already closed the channel set.
515
+ // An exclusive allowlist already closed the channel set. An access request
516
+ // routed to every channel is delivered even when the decision engine
517
+ // suppressed it (step 2.5c never lets one go undelivered), so it is made
518
+ // deliverable first: routing enforcement leaves a suppressed decision
519
+ // alone, which would leave the request on the in-app card alone.
520
+ if (
521
+ signal.sourceEventName === "ingress.access_request" &&
522
+ signal.routingIntent === "all_channels" &&
523
+ !decision.shouldNotify
524
+ ) {
525
+ decision = {
526
+ ...decision,
527
+ shouldNotify: true,
528
+ reasoningSummary: `${decision.reasoningSummary} (notify forced: access request routed to all channels)`,
529
+ };
530
+ }
516
531
  if (!exclusiveAllowlist) {
517
532
  decision = enforceRoutingIntent(
518
533
  decision,
@@ -326,14 +326,17 @@ export async function notifyGuardianOfAccessRequest(
326
326
  });
327
327
 
328
328
  let vellumDeliveryIdPromise: Promise<string | undefined> | undefined;
329
- // When the access request originates from a text channel with
330
- // notification delivery support (Slack, Telegram) and the guardian was
331
- // resolved via a verified same-channel contact, route the notification
332
- // only to that channel to reduce noise. Phone is excluded because it
333
- // is not a deliverable notification channel.
334
- // When the guardian was NOT verified on the source channel (e.g. resolved
335
- // via vellum anchor), route to all channels so the guardian can see
336
- // the request on desktop/other channels where they ARE verified.
329
+ // Where the card goes:
330
+ // - Slack or Telegram with the guardian verified on that same channel: only
331
+ // that channel, where the card is answered in place.
332
+ // - Email, for a sender who was denied: every channel the guardian is
333
+ // connected on. The card cannot be delivered or answered by email, and
334
+ // the in-app copy alone is easy to miss for a guardian who handles
335
+ // requests in a chat app. An admitted sender's nudge blocks nobody, so it
336
+ // takes the default below.
337
+ // - Anywhere else: the channels the decision engine selects. The in-app
338
+ // card is always among them (`emit-signal.ts` forces it for access
339
+ // requests).
337
340
  const TEXT_CHANNELS_WITH_DELIVERY: ReadonlySet<string> = new Set([
338
341
  "slack",
339
342
  "telegram",
@@ -355,7 +358,11 @@ export async function notifyGuardianOfAccessRequest(
355
358
  sourceContextId: `access-req-${sourceChannel}-${actorExternalId}`,
356
359
  requiresConversation: true,
357
360
  ...(vellumConversationAffinity ?? {}),
358
- ...(sameChannelOnly ? { routingIntent: "single_channel" as const } : {}),
361
+ ...(sameChannelOnly
362
+ ? { routingIntent: "single_channel" as const }
363
+ : sourceChannel === "email" && trigger === "denied"
364
+ ? { routingIntent: "all_channels" as const }
365
+ : {}),
359
366
  attentionHints: {
360
367
  requiresAction: true,
361
368
  // An admitted sender is already conversing — the guardian should
@@ -6,6 +6,7 @@ import {
6
6
  isHandshakeOffered,
7
7
  isWorkspaceVouchedIdentity,
8
8
  parseRequesterSignals,
9
+ requesterCanCompleteHandshake,
9
10
  resolveTrustBinding,
10
11
  serializeRequesterSignals,
11
12
  VERIFIED_VIA_CHANNEL_CLAIM,
@@ -117,13 +118,18 @@ describe("handshake policy", () => {
117
118
  expect(isHandshakeOffered("phone", {})).toBe(false);
118
119
  });
119
120
 
121
+ test("not offered on email: the code has no route to the sender", () => {
122
+ expect(requesterCanCompleteHandshake("email", {})).toBe(false);
123
+ expect(isHandshakeOffered("email", {})).toBe(false);
124
+ expect(isHandshakeOffered("email", { isStranger: true })).toBe(false);
125
+ });
126
+
120
127
  test("leads for externals, strangers, and identity-less channels", () => {
121
128
  expect(isHandshakeOffered("slack", { isStranger: true })).toBe(true);
122
129
  expect(isHandshakeOffered("slack", { isRestricted: true })).toBe(true);
123
130
  // Unknown signals (users.info failure) fail toward the handshake.
124
131
  expect(isHandshakeOffered("slack", {})).toBe(true);
125
132
  expect(isHandshakeOffered("telegram", {})).toBe(true);
126
- expect(isHandshakeOffered("email", {})).toBe(true);
127
133
  });
128
134
  });
129
135
 
@@ -145,6 +151,16 @@ describe("introduction action lists", () => {
145
151
  }
146
152
  });
147
153
 
154
+ test("email sender: direct trust leads, no code option", () => {
155
+ const actions = buildIntroductionActions("email", {});
156
+ expect(actions.map((a) => a.id)).toEqual([
157
+ "trust",
158
+ "leave_unverified",
159
+ "block",
160
+ ]);
161
+ expect(actions[0].label).toBe("Trust");
162
+ });
163
+
148
164
  test("external: handshake leads, trust is 'Trust anyway'", () => {
149
165
  const actions = buildIntroductionActions("slack", { isStranger: true });
150
166
  expect(actions.map((a) => a.id)).toEqual([
@@ -198,11 +198,27 @@ export interface IntroductionActionOption {
198
198
  emphasis: GuardianActionEmphasis;
199
199
  }
200
200
 
201
+ /**
202
+ * Whether this requester could ever complete the code handshake.
203
+ *
204
+ * A bot cannot return a code. An email sender cannot be sent one: approval
205
+ * has no delivery route for email, so the guardian would have to pass the
206
+ * code on outside the product, and the address the code would prove is
207
+ * already checked on every message (a `From:` that fails DMARC/DKIM reaches
208
+ * the runtime as a stranger).
209
+ */
210
+ export function requesterCanCompleteHandshake(
211
+ sourceChannel: string | undefined,
212
+ signals: RequesterIdentitySignals,
213
+ ): boolean {
214
+ return signals.isBot !== true && sourceChannel !== "email";
215
+ }
216
+
201
217
  /**
202
218
  * Whether the verification handshake is offered for this requester.
203
219
  *
204
220
  * The handshake is the exception, not the default:
205
- * - never for bots/integrations — a bot cannot return a code;
221
+ * - never where it cannot complete (see {@link requesterCanCompleteHandshake});
206
222
  * - not for workspace-vouched identities — the platform already
207
223
  * authenticated them;
208
224
  * - not on voice — a phone call has no text handshake path;
@@ -212,7 +228,7 @@ export function isHandshakeOffered(
212
228
  sourceChannel: string | undefined,
213
229
  signals: RequesterIdentitySignals,
214
230
  ): boolean {
215
- if (signals.isBot === true) {
231
+ if (!requesterCanCompleteHandshake(sourceChannel, signals)) {
216
232
  return false;
217
233
  }
218
234
  if (sourceChannel === "phone") {
@@ -225,15 +241,16 @@ export function isHandshakeOffered(
225
241
  * Build the ordered introduction-card action list for a requester. The first
226
242
  * action is the emphasized default:
227
243
  *
228
- * workspace member / bot / voice: [ Trust ] [ Leave unverified ] [ Block ]
229
- * external / stranger / guest: [ Verify with a code ] [ Trust anyway ]
230
- * [ Leave unverified ] [ Block ]
244
+ * workspace member / bot / voice / email: [ Trust ] [ Leave unverified ] [ Block ]
245
+ * external / stranger / guest: [ Verify with a code ] [ Trust anyway ]
246
+ * [ Leave unverified ] [ Block ]
231
247
  *
232
248
  * `leave_unverified` is the silent "park" outcome: it leaves the sender an
233
249
  * `unverified` contact and never notifies the requester. It is NOT a guaranteed
234
250
  * keep-out — an `unverified` contact is still admitted under the permissive
235
251
  * admission floors (`any_contact`, `strangers`); the hard keep-out is `block`
236
- * (→ revoked). The code option is NEVER rendered for a bot.
252
+ * (→ revoked). The code option is never rendered where the handshake cannot
253
+ * complete (a bot, an email sender; see {@link requesterCanCompleteHandshake}).
237
254
  */
238
255
  export function buildIntroductionActions(
239
256
  sourceChannel: string | undefined,