@vellumai/assistant 0.9.0-dev.202606182057.d0c9925 → 0.9.0-dev.202606182147.d5eebd3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606182057.d0c9925",
3
+ "version": "0.9.0-dev.202606182147.d5eebd3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,278 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("../util/logger.js", () => ({
4
+ getLogger: () =>
5
+ new Proxy({} as Record<string, unknown>, {
6
+ get: () => () => {},
7
+ }),
8
+ truncateForLog: (value: string) => value,
9
+ }));
10
+
11
+ const completeSurfaceAndNotify = mock(() => {});
12
+ mock.module("../daemon/conversation-surfaces.js", () => ({
13
+ completeSurfaceAndNotify,
14
+ }));
15
+
16
+ const withdrawSlackApprovalCard = mock(
17
+ async (_params: Record<string, unknown>) => {},
18
+ );
19
+ mock.module("../messaging/providers/slack/withdraw.js", () => ({
20
+ withdrawSlackApprovalCard,
21
+ }));
22
+
23
+ import { withdrawGuardianRequestCards } from "../approvals/guardian-card-withdrawal.js";
24
+ import {
25
+ type CanonicalGuardianRequest,
26
+ createCanonicalGuardianDelivery,
27
+ createCanonicalGuardianRequest,
28
+ listCanonicalGuardianDeliveries,
29
+ } from "../memory/canonical-guardian-store.js";
30
+ import { getDb } from "../memory/db-connection.js";
31
+ import { initializeDb } from "../memory/db-init.js";
32
+ import { recordCanonicalChannelDelivery } from "../notifications/canonical-delivery-recorder.js";
33
+ import type { NotificationDeliveryResult } from "../notifications/types.js";
34
+
35
+ initializeDb();
36
+
37
+ const PRINCIPAL_ID = "withdrawal-test-principal";
38
+
39
+ function resetTables(): void {
40
+ const db = getDb();
41
+ db.run("DELETE FROM canonical_guardian_deliveries");
42
+ db.run("DELETE FROM canonical_guardian_requests");
43
+ }
44
+
45
+ function makeRequest(
46
+ overrides: Partial<Parameters<typeof createCanonicalGuardianRequest>[0]> = {},
47
+ ): CanonicalGuardianRequest {
48
+ return createCanonicalGuardianRequest({
49
+ kind: "access_request",
50
+ sourceType: "channel",
51
+ sourceChannel: "slack",
52
+ guardianPrincipalId: PRINCIPAL_ID,
53
+ ...overrides,
54
+ });
55
+ }
56
+
57
+ describe("withdrawGuardianRequestCards", () => {
58
+ beforeEach(() => {
59
+ resetTables();
60
+ completeSurfaceAndNotify.mockClear();
61
+ withdrawSlackApprovalCard.mockClear();
62
+ });
63
+
64
+ test("withdraws + broadcasts the in-app card when the decision came from another surface", async () => {
65
+ const req = makeRequest();
66
+ createCanonicalGuardianDelivery({
67
+ requestId: req.id,
68
+ destinationChannel: "vellum",
69
+ destinationConversationId: "conv-1",
70
+ });
71
+
72
+ await withdrawGuardianRequestCards({
73
+ request: req,
74
+ status: "approved",
75
+ originChannel: "slack",
76
+ });
77
+
78
+ expect(completeSurfaceAndNotify).toHaveBeenCalledTimes(1);
79
+ expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
80
+ "conv-1",
81
+ `access-request-${req.id}`,
82
+ "Approved",
83
+ );
84
+ });
85
+
86
+ test("skips the in-app card when the decision originated in-app", async () => {
87
+ const req = makeRequest();
88
+ createCanonicalGuardianDelivery({
89
+ requestId: req.id,
90
+ destinationChannel: "vellum",
91
+ destinationConversationId: "conv-1",
92
+ });
93
+
94
+ await withdrawGuardianRequestCards({
95
+ request: req,
96
+ status: "approved",
97
+ originChannel: "vellum",
98
+ });
99
+
100
+ // The acting in-app client already completed its own card optimistically.
101
+ expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
102
+ });
103
+
104
+ test("withdraws the Slack card with decider and decision time", async () => {
105
+ const req = makeRequest({ decidedByExternalUserId: "U-guardian" });
106
+ createCanonicalGuardianDelivery({
107
+ requestId: req.id,
108
+ destinationChannel: "slack",
109
+ destinationChatId: "C123",
110
+ destinationMessageId: "1700000000.0001",
111
+ });
112
+
113
+ await withdrawGuardianRequestCards({
114
+ request: req,
115
+ status: "denied",
116
+ originChannel: "vellum",
117
+ });
118
+
119
+ expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
120
+ const [params] = withdrawSlackApprovalCard.mock.calls[0];
121
+ expect(params).toMatchObject({
122
+ channel: "C123",
123
+ messageTs: "1700000000.0001",
124
+ status: "denied",
125
+ decidedByExternalUserId: "U-guardian",
126
+ });
127
+ expect(typeof (params as { decidedAtMs?: number }).decidedAtMs).toBe(
128
+ "number",
129
+ );
130
+ });
131
+
132
+ test("skips the Slack edit when no channel message id was captured", async () => {
133
+ const req = makeRequest();
134
+ createCanonicalGuardianDelivery({
135
+ requestId: req.id,
136
+ destinationChannel: "slack",
137
+ destinationChatId: "C123",
138
+ });
139
+
140
+ await withdrawGuardianRequestCards({ request: req, status: "approved" });
141
+
142
+ expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
143
+ });
144
+
145
+ test("withdraws every surface (including in-app broadcast) when no origin channel", async () => {
146
+ const req = makeRequest();
147
+ createCanonicalGuardianDelivery({
148
+ requestId: req.id,
149
+ destinationChannel: "vellum",
150
+ destinationConversationId: "conv-1",
151
+ });
152
+ createCanonicalGuardianDelivery({
153
+ requestId: req.id,
154
+ destinationChannel: "slack",
155
+ destinationChatId: "C1",
156
+ destinationMessageId: "1.0",
157
+ });
158
+
159
+ await withdrawGuardianRequestCards({ request: req, status: "expired" });
160
+
161
+ expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
162
+ "conv-1",
163
+ `access-request-${req.id}`,
164
+ "Expired",
165
+ );
166
+ expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ test("ignores channels without in-place edit support (telegram)", async () => {
170
+ const req = makeRequest({ sourceChannel: "telegram" });
171
+ createCanonicalGuardianDelivery({
172
+ requestId: req.id,
173
+ destinationChannel: "telegram",
174
+ destinationChatId: "T1",
175
+ destinationMessageId: "9",
176
+ });
177
+
178
+ await withdrawGuardianRequestCards({ request: req, status: "approved" });
179
+
180
+ expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
181
+ expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
182
+ });
183
+
184
+ test("is best-effort: a failing surface never blocks the others or throws", async () => {
185
+ withdrawSlackApprovalCard.mockImplementationOnce(async () => {
186
+ throw new Error("slack unavailable");
187
+ });
188
+ const req = makeRequest();
189
+ createCanonicalGuardianDelivery({
190
+ requestId: req.id,
191
+ destinationChannel: "slack",
192
+ destinationChatId: "C1",
193
+ destinationMessageId: "1.0",
194
+ });
195
+ createCanonicalGuardianDelivery({
196
+ requestId: req.id,
197
+ destinationChannel: "vellum",
198
+ destinationConversationId: "conv-1",
199
+ });
200
+
201
+ await expect(
202
+ withdrawGuardianRequestCards({
203
+ request: req,
204
+ status: "approved",
205
+ originChannel: "telegram",
206
+ }),
207
+ ).resolves.toBeUndefined();
208
+
209
+ // The in-app card was still withdrawn despite the Slack failure.
210
+ expect(completeSurfaceAndNotify).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ test("tool-approval cards resolve to the tool-approval surface id", async () => {
214
+ const req = makeRequest({ kind: "tool_approval", toolName: "shell" });
215
+ createCanonicalGuardianDelivery({
216
+ requestId: req.id,
217
+ destinationChannel: "vellum",
218
+ destinationConversationId: "conv-1",
219
+ });
220
+
221
+ await withdrawGuardianRequestCards({
222
+ request: req,
223
+ status: "approved",
224
+ originChannel: "telegram",
225
+ });
226
+
227
+ expect(completeSurfaceAndNotify).toHaveBeenCalledWith(
228
+ "conv-1",
229
+ `tool-approval-${req.id}`,
230
+ "Approved",
231
+ );
232
+ });
233
+ });
234
+
235
+ describe("recordCanonicalChannelDelivery", () => {
236
+ beforeEach(() => {
237
+ resetTables();
238
+ withdrawSlackApprovalCard.mockClear();
239
+ });
240
+
241
+ test("captures the channel-native message id so the card can be withdrawn", async () => {
242
+ const req = makeRequest();
243
+ const result: NotificationDeliveryResult = {
244
+ channel: "slack",
245
+ destination: "C999",
246
+ status: "sent",
247
+ messageId: "1700000000.1234",
248
+ };
249
+
250
+ const delivery = recordCanonicalChannelDelivery(req.id, result);
251
+
252
+ expect(delivery.destinationChannel).toBe("slack");
253
+ expect(delivery.destinationChatId).toBe("C999");
254
+ expect(delivery.destinationMessageId).toBe("1700000000.1234");
255
+
256
+ // End-to-end: the recorded delivery is now editable by the withdrawal path.
257
+ await withdrawGuardianRequestCards({
258
+ request: req,
259
+ status: "approved",
260
+ originChannel: "vellum",
261
+ });
262
+ const [params] = withdrawSlackApprovalCard.mock.calls[0];
263
+ expect((params as { messageTs?: string }).messageTs).toBe(
264
+ "1700000000.1234",
265
+ );
266
+ });
267
+
268
+ test("omits chat id when the destination is empty", () => {
269
+ const req = makeRequest();
270
+ const delivery = recordCanonicalChannelDelivery(req.id, {
271
+ channel: "slack",
272
+ destination: "",
273
+ status: "sent",
274
+ });
275
+ expect(delivery.destinationChatId).toBeNull();
276
+ expect(listCanonicalGuardianDeliveries(req.id)).toHaveLength(1);
277
+ });
278
+ });
@@ -92,7 +92,7 @@ mock.module("../runtime/channel-verification-service.js", () => ({
92
92
  return null;
93
93
  },
94
94
  createOutboundSession: () => ({
95
- conversationId: "test-session",
95
+ sessionId: "test-session",
96
96
  secret: "123456",
97
97
  }),
98
98
  bindSessionIdentity: () => {},
@@ -108,16 +108,23 @@ mock.module("../runtime/channel-verification-service.js", () => ({
108
108
  }),
109
109
  }));
110
110
 
111
- // Mock gateway client — capture delivery calls
111
+ // Mock gateway client — capture delivery calls. `failDeliveryWhen` lets a test
112
+ // simulate a delivery failure for a specific payload (e.g. a DM that can't be
113
+ // opened) so fallback paths can be exercised.
112
114
  const deliveredReplies: Array<{
113
115
  url: string;
114
116
  payload: Record<string, unknown>;
115
117
  }> = [];
118
+ let failDeliveryWhen: ((payload: Record<string, unknown>) => boolean) | null =
119
+ null;
116
120
  mock.module("../runtime/gateway-client.js", () => ({
117
121
  deliverChannelReply: async (
118
122
  url: string,
119
123
  payload: Record<string, unknown>,
120
124
  ) => {
125
+ if (failDeliveryWhen?.(payload)) {
126
+ throw new Error("simulated delivery failure");
127
+ }
121
128
  deliveredReplies.push({ url, payload });
122
129
  return { ok: true };
123
130
  },
@@ -150,6 +157,7 @@ mock.module("../config/env.js", () => ({
150
157
  import { applyCanonicalGuardianDecision } from "../approvals/guardian-decision-primitive.js";
151
158
  import type { ActorContext } from "../approvals/guardian-request-resolvers.js";
152
159
  import { getResolver } from "../approvals/guardian-request-resolvers.js";
160
+ import { upsertContactChannel } from "../contacts/contacts-write.js";
153
161
  import type { TrustContext } from "../daemon/trust-context.js";
154
162
  import {
155
163
  createCanonicalGuardianRequest,
@@ -1088,3 +1096,212 @@ describe("cross-milestone integration checks", () => {
1088
1096
  });
1089
1097
  });
1090
1098
 
1099
+ // ===========================================================================
1100
+ // (g) access_request resolver: requester verification-code delivery
1101
+ //
1102
+ // On approval the requester must receive the 6-digit code so the guardian
1103
+ // never has to relay it by hand. On Slack the code is DM'd straight to the
1104
+ // requester (a private path is guaranteed via their user ID); other channels
1105
+ // keep the courier message because a private path to the requester is not
1106
+ // guaranteed there (e.g. a group chat would leak the secret).
1107
+ // ===========================================================================
1108
+
1109
+ describe("(g) access_request resolver: requester code delivery", () => {
1110
+ const REQUESTER_UID = "U_REQUESTER";
1111
+ const GUARDIAN_UID = "U_GUARDIAN";
1112
+
1113
+ function createAccessRequest(overrides: Record<string, unknown> = {}) {
1114
+ return createCanonicalGuardianRequest({
1115
+ id: `access-req-${Date.now()}-${Math.random().toString(36).slice(2)}`,
1116
+ kind: "access_request",
1117
+ sourceType: "channel",
1118
+ sourceChannel: "slack",
1119
+ conversationId: "conv-access-slack",
1120
+ requesterExternalUserId: REQUESTER_UID,
1121
+ requesterChatId: "C_SHARED_CHANNEL",
1122
+ guardianExternalUserId: GUARDIAN_UID,
1123
+ guardianPrincipalId: "test-principal-id",
1124
+ toolName: "ingress_access_request",
1125
+ expiresAt: Date.now() + 60_000,
1126
+ ...overrides,
1127
+ });
1128
+ }
1129
+
1130
+ beforeEach(() => {
1131
+ resetTables();
1132
+ deliveredReplies.length = 0;
1133
+ emittedSignals.length = 0;
1134
+ failDeliveryWhen = null;
1135
+ });
1136
+
1137
+ test("on-channel Slack approval DMs the verification code to the requester", async () => {
1138
+ const req = createAccessRequest();
1139
+
1140
+ const result = await applyCanonicalGuardianDecision({
1141
+ requestId: req.id,
1142
+ action: "approve_once",
1143
+ actorContext: guardianActor({
1144
+ channel: "slack",
1145
+ actorExternalUserId: GUARDIAN_UID,
1146
+ }),
1147
+ channelDeliveryContext: {
1148
+ replyCallbackUrl:
1149
+ "http://localhost:3000/deliver/slack?threadTs=111.222",
1150
+ guardianChatId: "C_SHARED_CHANNEL",
1151
+ assistantId: "self",
1152
+ },
1153
+ });
1154
+
1155
+ expect(result.applied).toBe(true);
1156
+
1157
+ // The requester receives the actual code in their DM (chatId = user ID),
1158
+ // not a "ask the guardian" courier message.
1159
+ const requesterCodeReply = deliveredReplies.find(
1160
+ (r) =>
1161
+ r.payload.chatId === REQUESTER_UID &&
1162
+ typeof r.payload.text === "string" &&
1163
+ (r.payload.text as string).includes("123456"),
1164
+ );
1165
+ expect(requesterCodeReply).toBeDefined();
1166
+ expect(requesterCodeReply!.payload.text).toContain(
1167
+ "your access request was approved",
1168
+ );
1169
+ // The code DM is durable, never ephemeral.
1170
+ expect(requesterCodeReply!.payload.ephemeral).toBeUndefined();
1171
+ // threadTs (the guardian's channel thread) is stripped for the DM.
1172
+ expect(requesterCodeReply!.url).not.toContain("threadTs");
1173
+
1174
+ // No courier "receive from the guardian" message goes to the requester.
1175
+ const courier = deliveredReplies.find(
1176
+ (r) =>
1177
+ typeof r.payload.text === "string" &&
1178
+ (r.payload.text as string).includes("receive from the guardian"),
1179
+ );
1180
+ expect(courier).toBeUndefined();
1181
+ });
1182
+
1183
+ test("desktop-decided approval DMs the code to the Slack requester via the deliver path", async () => {
1184
+ const req = createAccessRequest();
1185
+
1186
+ const result = await applyCanonicalGuardianDecision({
1187
+ requestId: req.id,
1188
+ action: "approve_once",
1189
+ // Desktop decision: no channelDeliveryContext, actor on the vellum channel.
1190
+ actorContext: guardianActor({
1191
+ channel: "vellum",
1192
+ actorExternalUserId: undefined,
1193
+ }),
1194
+ });
1195
+
1196
+ expect(result.applied).toBe(true);
1197
+
1198
+ const requesterCodeReply = deliveredReplies.find(
1199
+ (r) =>
1200
+ r.payload.chatId === REQUESTER_UID &&
1201
+ typeof r.payload.text === "string" &&
1202
+ (r.payload.text as string).includes("123456"),
1203
+ );
1204
+ expect(requesterCodeReply).toBeDefined();
1205
+ expect(requesterCodeReply!.url).toContain("/deliver/slack");
1206
+ expect(requesterCodeReply!.payload.text).toContain(
1207
+ "your access request was approved",
1208
+ );
1209
+ });
1210
+
1211
+ test("non-Slack channel keeps the courier message and never delivers the code to the requester chat", async () => {
1212
+ const req = createAccessRequest({
1213
+ sourceChannel: "telegram",
1214
+ requesterChatId: "requester-chat-1",
1215
+ conversationId: "conv-access-telegram",
1216
+ });
1217
+
1218
+ const result = await applyCanonicalGuardianDecision({
1219
+ requestId: req.id,
1220
+ action: "approve_once",
1221
+ actorContext: guardianActor({
1222
+ channel: "telegram",
1223
+ actorExternalUserId: GUARDIAN_UID,
1224
+ }),
1225
+ channelDeliveryContext: {
1226
+ replyCallbackUrl: "http://localhost:3000/deliver/telegram",
1227
+ guardianChatId: "guardian-chat-1",
1228
+ assistantId: "self",
1229
+ },
1230
+ });
1231
+
1232
+ expect(result.applied).toBe(true);
1233
+
1234
+ // The requester is told to expect the code from the guardian; the secret is
1235
+ // never delivered to the requester's (possibly group) chat.
1236
+ const requesterReply = deliveredReplies.find(
1237
+ (r) => r.payload.chatId === "requester-chat-1",
1238
+ );
1239
+ expect(requesterReply).toBeDefined();
1240
+ expect(requesterReply!.payload.text).toContain("receive from the guardian");
1241
+ expect(requesterReply!.payload.text).not.toContain("123456");
1242
+ });
1243
+
1244
+ test("Slack shared-channel fallback posts an ephemeral notice to the channel when the DM fails", async () => {
1245
+ const req = createAccessRequest();
1246
+
1247
+ // Make the direct DM (to the U... user ID) fail so the courier fallback runs.
1248
+ failDeliveryWhen = (payload) => payload.chatId === REQUESTER_UID;
1249
+
1250
+ const result = await applyCanonicalGuardianDecision({
1251
+ requestId: req.id,
1252
+ action: "approve_once",
1253
+ actorContext: guardianActor({
1254
+ channel: "slack",
1255
+ actorExternalUserId: GUARDIAN_UID,
1256
+ }),
1257
+ channelDeliveryContext: {
1258
+ replyCallbackUrl:
1259
+ "http://localhost:3000/deliver/slack?threadTs=111.222",
1260
+ guardianChatId: "C_SHARED_CHANNEL",
1261
+ assistantId: "self",
1262
+ },
1263
+ });
1264
+
1265
+ expect(result.applied).toBe(true);
1266
+
1267
+ // The courier notice falls back to an ephemeral message targeting the
1268
+ // originating channel (C...), since chat.postEphemeral needs a channel ID —
1269
+ // not the requester's user ID.
1270
+ const courier = deliveredReplies.find(
1271
+ (r) =>
1272
+ typeof r.payload.text === "string" &&
1273
+ (r.payload.text as string).includes("receive from the guardian"),
1274
+ );
1275
+ expect(courier).toBeDefined();
1276
+ expect(courier!.payload.chatId).toBe("C_SHARED_CHANNEL");
1277
+ expect(courier!.payload.ephemeral).toBe(true);
1278
+ expect(courier!.payload.user).toBe(REQUESTER_UID);
1279
+ });
1280
+
1281
+ test("guardian-facing reply uses the requester's display name, not the raw ID", async () => {
1282
+ // Seed a contact so the resolver can resolve a display name.
1283
+ upsertContactChannel({
1284
+ sourceChannel: "slack",
1285
+ externalUserId: REQUESTER_UID,
1286
+ displayName: "Alice",
1287
+ status: "unverified",
1288
+ });
1289
+
1290
+ const req = createAccessRequest();
1291
+
1292
+ const result = await applyCanonicalGuardianDecision({
1293
+ requestId: req.id,
1294
+ action: "approve_once",
1295
+ // Desktop decision → resolver returns guardianReplyText for assertion.
1296
+ actorContext: guardianActor({
1297
+ channel: "vellum",
1298
+ actorExternalUserId: undefined,
1299
+ }),
1300
+ });
1301
+
1302
+ expect(result.applied).toBe(true);
1303
+ const replyText = result.applied ? result.resolverReplyText : undefined;
1304
+ expect(replyText).toContain("Alice");
1305
+ expect(replyText).not.toContain(REQUESTER_UID);
1306
+ });
1307
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Cross-surface withdrawal of guardian approval cards.
3
+ *
4
+ * A single guardian request is projected onto every surface it was delivered
5
+ * to — the in-app Vellum card, a Slack message, etc. When the request reaches a
6
+ * terminal status its cards must stop offering live actions on *all* surfaces,
7
+ * not just the one the guardian acted on. This is the withdrawal counterpart to
8
+ * the unified card *rendering* dispatcher (`notifications/approval-card-data.ts`):
9
+ * rendering projects `pending` onto each surface, withdrawal projects the
10
+ * terminal status back onto each surface.
11
+ *
12
+ * Withdrawal preserves the card's information and removes only the live
13
+ * affordances (it does not delete the message/card) so each surface keeps an
14
+ * audit trail of what was decided.
15
+ *
16
+ * It is driven off `canonical_guardian_deliveries` — the per-request registry of
17
+ * where each card was sent — so it stays correct as surfaces are added and is
18
+ * agnostic to which surface originated the decision.
19
+ *
20
+ * Best-effort by contract: the request is already resolved (CAS committed)
21
+ * before this runs, so a failed edit must never surface as a decision failure.
22
+ * Every surface is attempted independently; one failure never blocks the rest.
23
+ */
24
+
25
+ import { completeSurfaceAndNotify } from "../daemon/conversation-surfaces.js";
26
+ import {
27
+ type CanonicalGuardianDelivery,
28
+ type CanonicalGuardianRequest,
29
+ type CanonicalRequestStatus,
30
+ listCanonicalGuardianDeliveries,
31
+ } from "../memory/canonical-guardian-store.js";
32
+ import { withdrawSlackApprovalCard } from "../messaging/providers/slack/withdraw.js";
33
+ import { approvalCardSurfaceId } from "../notifications/approval-card-data.js";
34
+ import { getLogger } from "../util/logger.js";
35
+
36
+ const log = getLogger("guardian-card-withdrawal");
37
+
38
+ /** Completion-summary label shown on an in-app card for a resolved request. */
39
+ const SURFACE_STATUS_LABELS: Partial<Record<CanonicalRequestStatus, string>> = {
40
+ approved: "Approved",
41
+ denied: "Denied",
42
+ expired: "Expired",
43
+ cancelled: "Cancelled",
44
+ };
45
+
46
+ export interface WithdrawGuardianCardsParams {
47
+ /** The request, already transitioned to its terminal status. */
48
+ request: CanonicalGuardianRequest;
49
+ /** Terminal status to reflect on each card. */
50
+ status: CanonicalRequestStatus;
51
+ /**
52
+ * Channel the decision originated on, when applicable.
53
+ *
54
+ * The acting in-app client completes its own card optimistically, so the
55
+ * in-app card is skipped when the decision originated in-app; it is withdrawn
56
+ * here only when the decision came from another surface. Omit (e.g. the expiry
57
+ * sweep) to withdraw every surface.
58
+ */
59
+ originChannel?: string;
60
+ }
61
+
62
+ /**
63
+ * Withdraw a resolved request's approval cards across all delivery surfaces.
64
+ * Never throws.
65
+ */
66
+ export async function withdrawGuardianRequestCards(
67
+ params: WithdrawGuardianCardsParams,
68
+ ): Promise<void> {
69
+ const { request, status, originChannel } = params;
70
+
71
+ let deliveries: CanonicalGuardianDelivery[];
72
+ try {
73
+ deliveries = listCanonicalGuardianDeliveries(request.id);
74
+ } catch (err) {
75
+ log.warn(
76
+ { err, requestId: request.id },
77
+ "Failed to list deliveries for card withdrawal",
78
+ );
79
+ return;
80
+ }
81
+
82
+ for (const delivery of deliveries) {
83
+ try {
84
+ if (delivery.destinationChannel === "vellum") {
85
+ withdrawVellumCard(request, delivery, status, originChannel);
86
+ } else if (delivery.destinationChannel === "slack") {
87
+ await withdrawSlackCard(request, delivery, status);
88
+ }
89
+ // Telegram/WhatsApp direct delivery can't edit a message in place (it
90
+ // would post a new one), so their stale clicks are left to the existing
91
+ // "already resolved" reply until in-place edit support lands.
92
+ } catch (err) {
93
+ log.warn(
94
+ {
95
+ err,
96
+ requestId: request.id,
97
+ channel: delivery.destinationChannel,
98
+ },
99
+ "Failed to withdraw guardian card on surface (non-fatal)",
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Withdraw the in-app approval card so it stops offering live actions while
107
+ * keeping its content. Skipped when the decision originated in-app — the acting
108
+ * client already completed the card itself.
109
+ */
110
+ function withdrawVellumCard(
111
+ request: CanonicalGuardianRequest,
112
+ delivery: CanonicalGuardianDelivery,
113
+ status: CanonicalRequestStatus,
114
+ originChannel: string | undefined,
115
+ ): void {
116
+ if (originChannel === "vellum") return;
117
+ if (!delivery.destinationConversationId) return;
118
+ const surfaceId = approvalCardSurfaceId(request.kind, request.id);
119
+ if (!surfaceId) return;
120
+ completeSurfaceAndNotify(
121
+ delivery.destinationConversationId,
122
+ surfaceId,
123
+ SURFACE_STATUS_LABELS[status] ?? "Resolved",
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Edit the Slack message in place to its resolved state — original card content
129
+ * preserved, action buttons removed, an outcome/decider/time line appended.
130
+ * No-ops when the channel-native message id was not captured at delivery time.
131
+ */
132
+ async function withdrawSlackCard(
133
+ request: CanonicalGuardianRequest,
134
+ delivery: CanonicalGuardianDelivery,
135
+ status: CanonicalRequestStatus,
136
+ ): Promise<void> {
137
+ if (!delivery.destinationChatId || !delivery.destinationMessageId) return;
138
+ await withdrawSlackApprovalCard({
139
+ channel: delivery.destinationChatId,
140
+ messageTs: delivery.destinationMessageId,
141
+ status,
142
+ decidedByExternalUserId: request.decidedByExternalUserId ?? undefined,
143
+ decidedAtMs: request.updatedAt,
144
+ });
145
+ }