@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.
- package/docs/architecture/scheduling.md +7 -5
- package/docs/guardian-request-flow.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
- package/src/__tests__/introduction-card-resolver.test.ts +53 -8
- package/src/__tests__/non-member-access-request.test.ts +48 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
- package/src/approvals/guardian-decision-primitive.ts +3 -2
- package/src/approvals/guardian-request-resolvers.ts +31 -24
- package/src/notifications/README.md +6 -6
- package/src/notifications/emit-signal.ts +16 -1
- package/src/runtime/access-request-helper.ts +16 -9
- package/src/runtime/introduction-policy.test.ts +17 -1
- package/src/runtime/introduction-policy.ts +23 -6
|
@@ -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` |
|
|
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
|
@@ -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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
);
|
|
404
|
-
expect(
|
|
405
|
-
expect(
|
|
406
|
-
expect(
|
|
407
|
-
expect(
|
|
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.
|
|
1312
|
-
// `
|
|
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: "
|
|
1318
|
-
requesterChatId: "requester
|
|
1319
|
-
sourceConversationId: "conv-access-
|
|
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
|
|
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
|
|
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
|
|
623
|
-
* The generic decision pair folds onto the card outcomes (`reject` →
|
|
624
|
-
* `leave_unverified`, `approve_once` → `verify_code`)
|
|
625
|
-
* map to themselves
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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
|
-
):
|
|
635
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
|
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` |
|
|
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`**:
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// the
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
230
|
-
*
|
|
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
|
|
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,
|