@vellumai/assistant 0.12.2-dev.202609181913.1108ac3 → 0.12.2-dev.202609182114.335fa56

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 (60) hide show
  1. package/ARCHITECTURE.md +9 -7
  2. package/docs/architecture/turn-actor.md +2 -0
  3. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
  4. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
  5. package/package.json +1 -1
  6. package/src/__tests__/assistant-event-targeting.test.ts +186 -0
  7. package/src/__tests__/assistant-stream-state.test.ts +7 -9
  8. package/src/__tests__/channel-retry-sweep.test.ts +9 -0
  9. package/src/__tests__/dm-backfill.test.ts +83 -1
  10. package/src/__tests__/emit-signal-routing-intent.test.ts +1 -1
  11. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
  12. package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
  13. package/src/__tests__/notification-telegram-adapter.test.ts +2 -2
  14. package/src/__tests__/process-message-display-content.test.ts +56 -0
  15. package/src/__tests__/reaction-persistence.test.ts +5 -0
  16. package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
  17. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
  18. package/src/__tests__/thread-backfill.test.ts +53 -0
  19. package/src/approvals/AGENTS.md +1 -1
  20. package/src/cli/commands/__tests__/notifications.test.ts +10 -10
  21. package/src/cli/commands/roadmap.help.ts +2 -3
  22. package/src/daemon/conversation-messaging.ts +19 -1
  23. package/src/daemon/message-provenance.ts +23 -0
  24. package/src/daemon/process-message.ts +13 -4
  25. package/src/ipc/assistant-server.ts +2 -0
  26. package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
  27. package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
  28. package/src/messaging/providers/discord/send.ts +3 -2
  29. package/src/messaging/providers/telegram-bot/send.test.ts +30 -1
  30. package/src/messaging/providers/telegram-bot/send.ts +10 -7
  31. package/src/notifications/README.md +3 -1
  32. package/src/notifications/__tests__/broadcaster.test.ts +1 -1
  33. package/src/notifications/__tests__/copy-composer.test.ts +1 -1
  34. package/src/notifications/__tests__/decision-engine.test.ts +4 -2
  35. package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
  36. package/src/notifications/__tests__/home-feed-side-effect.test.ts +6 -4
  37. package/src/notifications/copy-composer.ts +0 -23
  38. package/src/notifications/deterministic-checks.ts +1 -1
  39. package/src/notifications/home-feed-side-effect.ts +4 -6
  40. package/src/notifications/signal.ts +3 -18
  41. package/src/persistence/conversation-crud.ts +8 -0
  42. package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
  43. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
  44. package/src/plugins/defaults/memory/tools.test.ts +24 -1
  45. package/src/plugins/defaults/memory/tools.ts +12 -1
  46. package/src/runtime/AGENTS.md +2 -0
  47. package/src/runtime/assistant-event-hub.ts +16 -86
  48. package/src/runtime/assistant-event-targeting.ts +79 -0
  49. package/src/runtime/assistant-stream-state.ts +13 -112
  50. package/src/runtime/channel-retry-sweep.ts +1 -0
  51. package/src/runtime/http-types.ts +6 -0
  52. package/src/runtime/routes/events-routes.ts +2 -2
  53. package/src/runtime/routes/inbound-message-handler.ts +35 -38
  54. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
  55. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -0
  56. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
  57. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
  58. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
  59. package/src/util/canonicalize-identity.test.ts +21 -0
  60. package/src/util/canonicalize-identity.ts +16 -0
package/ARCHITECTURE.md CHANGED
@@ -300,7 +300,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
300
300
 
301
301
  **Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
302
302
 
303
- 1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The exception is on the gateway side: its replies to invite and verification codes it intercepts at ingress (`deliverVerificationReply` in `gateway/src/verification/reply-delivery.ts`) still POST to the callback URL, which no gateway route serves, so those replies are not delivered.
303
+ 1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method (`src/ipc/routes/channel-reply-ipc-routes.ts`), which resolves the transport from that URL.
304
304
  2. `api.ts` reads `phone_number_id` and `access_token` from the secure store and sends through the Cloud API `/{phoneNumberId}/messages` endpoint.
305
305
  3. `send.ts` splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
306
306
  4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared `apr:<requestId>:<action>` convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's `button_reply` back into callback data on ingress.
@@ -1930,12 +1930,14 @@ Keep-alive heartbeats (every 30 s by default):
1930
1930
 
1931
1931
  ### Key Source Files
1932
1932
 
1933
- | File | Role |
1934
- | ----------------------------------------------- | ------------------------------------------------------------------------------ |
1935
- | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
- | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
- | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()` — SSE route handler |
1938
- | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` → `publishAssistantEvent`) |
1933
+ | File | Role |
1934
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
1935
+ | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
+ | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
+ | `assistant/src/runtime/assistant-event-targeting.ts` | `matchesTargeting()`, the targeting check live fanout and replay share |
1938
+ | `assistant/src/runtime/assistant-stream-state.ts` | Per-assistant `seq` counter and ring buffer behind reconnect and `events/tail` replay |
1939
+ | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()`, the SSE route handler |
1940
+ | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` → `publishAssistantEvent`) |
1939
1941
 
1940
1942
  ---
1941
1943
 
@@ -29,6 +29,8 @@ Do not read `trustContext` or `currentTurnTrustContext` directly. Call sites han
29
29
 
30
30
  **Use `getTurnOrRestingTrust()`** for provenance stamped onto persisted rows (see `provenanceFromTrustContext`). Provenance is read back by the memory indexer, which runs extraction only for guardian rows, and by the transcript assembly, which wraps non-guardian user content before it reaches the model. The turn's actor is correct when a turn is running; rows persisted with no turn in flight (wake notices) must keep the owner's class, because stamping `"unknown"` there silently stops memory extraction for the owner's own flows.
31
31
 
32
+ That provenance describes the turn, so it is stamped on every row the turn writes, the assistant's replies and tool results included. Who wrote a row is a separate fact: `provenanceContactId` names the author's contact and needs positive evidence. It is added only where a person's own inbound words or actions are persisted (`actorAuthorProvenance`): a message channel ingress or its retry replay relays with an explicit `author`, unless that row is scripted or hidden, and the actor's reaction row. The shared user-message writer never infers an author from the conversation's trust, because machine-authored rows (ACP and subagent notifications, pointer turns) persist through it too. Rows the assistant writes during someone's turn never carry it. Slack backfill imports other people's messages, so the turn's actor says nothing about them; each backfilled sender is classified by the same gateway verdict live ingress uses (`resolve_inbound_trust`, see `inbound-stages/backfill-sender-provenance.ts`), which supplies both its trust class and its contact id.
33
+
32
34
  **Use `getTrustContext()`** when there is no turn: HTTP routes reporting conversation state, hydration, and persistence of conversation-level options. Also when a caller deliberately wants the conversation's owner rather than whoever is currently acting; that intent should be obvious from the call site, and if it is not, it is probably the wrong accessor.
33
35
 
34
36
  ## When the acting actor is unknown
@@ -24,13 +24,15 @@ export {
24
24
  PersistentIpcClient,
25
25
  } from "./ipc-client.js";
26
26
 
27
- // Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
27
+ // Outbound delivery contract: Zod schemas + derived types
28
28
  export {
29
29
  ApprovalActionOptionSchema,
30
30
  ApprovalUIMetadataSchema,
31
31
  AttachmentMetadataSchema,
32
32
  ChannelDeliveryResultSchema,
33
33
  ChannelReplyPayloadSchema,
34
+ DELIVER_GATEWAY_REPLY_IPC_METHOD,
35
+ GatewayReplyRequestSchema,
34
36
  MessageAudienceSchema,
35
37
  PermissionRequestDetailsSchema,
36
38
  StreamOpSchema,
@@ -45,6 +47,7 @@ export type {
45
47
  AttachmentMetadata,
46
48
  ChannelDeliveryResult,
47
49
  ChannelReplyPayload,
50
+ GatewayReplyRequest,
48
51
  MessageAudience,
49
52
  PermissionRequestDetails,
50
53
  StreamOp,
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Daemon → gateway outbound delivery contract.
2
+ * Outbound channel delivery contract.
3
3
  *
4
- * Zod schemas defining the wire format for channel replies delivered from
5
- * the daemon to the gateway via `POST /deliver/{channel}`. Both services
6
- * import from here so the contract is enforced at compile time.
4
+ * Zod schemas for a reply to a channel chat. The daemon constructs these
5
+ * payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
6
+ * them to the channel transport its callback URL names
7
+ * (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
8
+ * addressing form: no service serves it. A callback URL no
9
+ * transport owns (a managed callback carrying a `callback_token`) is POSTed
10
+ * over HTTP by `http-delivery.ts` instead.
7
11
  *
8
- * The daemon constructs these payloads in `deliverChannelReply()` and
9
- * `deliverApprovalPrompt()`; the gateway validates and dispatches them
10
- * to the target channel provider.
12
+ * The gateway sends through the same transports, never to a provider itself:
13
+ * a reply it composes for a message it answered at ingress goes to the daemon
14
+ * as a {@link GatewayReplyRequest}.
11
15
  */
12
16
 
13
17
  import type { KnownBlock } from "@slack/types";
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
279
283
 
280
284
  export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
281
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Gateway reply: gateway to daemon
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
292
+ * it has no HTTP route.
293
+ */
294
+ export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
295
+
296
+ /**
297
+ * A text reply the gateway composed for an inbound message it answered
298
+ * itself (a verification code, an invite redemption), which the daemon
299
+ * delivers through the channel transport `callbackUrl` names. The callback
300
+ * URL is the one the inbound message carried, so the reply lands in the chat
301
+ * and thread the person wrote from.
302
+ */
303
+ export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
304
+ chatId: true,
305
+ assistantId: true,
306
+ }).extend({
307
+ callbackUrl: z.string().min(1),
308
+ text: z.string().min(1),
309
+ });
310
+
311
+ export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
312
+
282
313
  // ---------------------------------------------------------------------------
283
314
  // Channel delivery result — gateway response
284
315
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181913.1108ac3",
3
+ "version": "0.12.2-dev.202609182114.335fa56",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for `matchesTargeting`, the one targeting check behind live fanout
3
+ * (`AssistantEventHub.publish`) and replay (`getReplayWindow`).
4
+ *
5
+ * The parity block runs every case through both paths and requires them to
6
+ * agree, so a rule added to one path alone fails here.
7
+ */
8
+ import { beforeEach, describe, expect, test } from "bun:test";
9
+
10
+ import type { AssistantEventEnvelope } from "../api/index.js";
11
+ import type { HostProxyCapability, InterfaceId } from "../channels/types.js";
12
+ import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
13
+ import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
14
+ import { matchesTargeting } from "../runtime/assistant-event-targeting.js";
15
+ import {
16
+ _resetStreamStateForTesting,
17
+ getReplayWindow,
18
+ stampAndBuffer,
19
+ } from "../runtime/assistant-stream-state.js";
20
+
21
+ const CONV = "conv-targeting";
22
+
23
+ interface ClientIdentity {
24
+ type: "client";
25
+ clientId: string;
26
+ interfaceId: InterfaceId;
27
+ capabilities: HostProxyCapability[];
28
+ actorPrincipalId?: string;
29
+ }
30
+
31
+ const MAC: ClientIdentity = {
32
+ type: "client",
33
+ clientId: "mac-1",
34
+ interfaceId: "macos",
35
+ capabilities: ["host_bash", "host_browser"],
36
+ actorPrincipalId: "principal-g",
37
+ };
38
+ const WEB: ClientIdentity = {
39
+ type: "client",
40
+ clientId: "web-1",
41
+ interfaceId: "web",
42
+ capabilities: [],
43
+ actorPrincipalId: "principal-g",
44
+ };
45
+ const OTHER_USER: ClientIdentity = {
46
+ type: "client",
47
+ clientId: "web-2",
48
+ interfaceId: "web",
49
+ capabilities: [],
50
+ actorPrincipalId: "principal-x",
51
+ };
52
+ const NO_PRINCIPAL: ClientIdentity = {
53
+ type: "client",
54
+ clientId: "legacy-1",
55
+ interfaceId: "web",
56
+ capabilities: [],
57
+ };
58
+ interface ProcessIdentity {
59
+ type: "process";
60
+ }
61
+
62
+ const PROCESS: ProcessIdentity = { type: "process" };
63
+
64
+ const SUBSCRIBERS: Array<[string, ClientIdentity | ProcessIdentity]> = [
65
+ ["mac", MAC],
66
+ ["web", WEB],
67
+ ["other user", OTHER_USER],
68
+ ["no principal", NO_PRINCIPAL],
69
+ ["process", PROCESS],
70
+ ];
71
+
72
+ /** Each targeting case with the subscribers it must reach, by label. */
73
+ const CASES: Array<{
74
+ name: string;
75
+ targeting: AssistantEventPublishOptions | undefined;
76
+ reaches: string[];
77
+ }> = [
78
+ {
79
+ name: "untargeted",
80
+ targeting: undefined,
81
+ reaches: ["mac", "web", "other user", "no principal", "process"],
82
+ },
83
+ {
84
+ name: "self-echo exclusion",
85
+ targeting: { excludeClientId: "web-1" },
86
+ reaches: ["mac", "other user", "no principal", "process"],
87
+ },
88
+ {
89
+ name: "interface",
90
+ targeting: { targetInterfaceId: "web" },
91
+ reaches: ["web", "other user", "no principal"],
92
+ },
93
+ {
94
+ name: "principal",
95
+ targeting: { targetActorPrincipalId: "principal-g" },
96
+ reaches: ["mac", "web"],
97
+ },
98
+ {
99
+ name: "client",
100
+ targeting: { targetClientId: "mac-1" },
101
+ reaches: ["mac"],
102
+ },
103
+ {
104
+ name: "capability",
105
+ targeting: { targetCapability: "host_browser" },
106
+ reaches: ["mac"],
107
+ },
108
+ {
109
+ name: "client without the capability",
110
+ targeting: { targetClientId: "web-1", targetCapability: "host_browser" },
111
+ reaches: [],
112
+ },
113
+ {
114
+ name: "principal and interface together",
115
+ targeting: {
116
+ targetActorPrincipalId: "principal-g",
117
+ targetInterfaceId: "web",
118
+ },
119
+ reaches: ["web"],
120
+ },
121
+ ];
122
+
123
+ function makeEvent(): AssistantEventEnvelope {
124
+ return {
125
+ id: `evt-${Math.random().toString(36).slice(2, 10)}`,
126
+ conversationId: CONV,
127
+ emittedAt: new Date().toISOString(),
128
+ message: { type: "assistant_text_delta", conversationId: CONV, text: "x" },
129
+ };
130
+ }
131
+
132
+ async function reachedLive(
133
+ targeting: AssistantEventPublishOptions | undefined,
134
+ ): Promise<string[]> {
135
+ const hub = new AssistantEventHub();
136
+ const reached: string[] = [];
137
+ for (const [label, identity] of SUBSCRIBERS) {
138
+ const callback = () => {
139
+ reached.push(label);
140
+ };
141
+ if (identity.type === "process") {
142
+ hub.subscribe({ type: "process", callback });
143
+ } else {
144
+ hub.subscribe({ ...identity, callback });
145
+ }
146
+ }
147
+ await hub.publish(makeEvent(), targeting);
148
+ return reached.sort();
149
+ }
150
+
151
+ function reachedOnReplay(
152
+ targeting: AssistantEventPublishOptions | undefined,
153
+ ): string[] {
154
+ _resetStreamStateForTesting();
155
+ stampAndBuffer(makeEvent(), targeting ? { targeting } : undefined);
156
+ return SUBSCRIBERS.filter(
157
+ ([, identity]) => (getReplayWindow(0, identity) ?? []).length > 0,
158
+ )
159
+ .map(([label]) => label)
160
+ .sort();
161
+ }
162
+
163
+ describe("matchesTargeting", () => {
164
+ for (const { name, targeting, reaches } of CASES) {
165
+ test(`${name}: reaches exactly its subscribers`, () => {
166
+ const reached = SUBSCRIBERS.filter(([, identity]) =>
167
+ matchesTargeting(targeting, identity),
168
+ ).map(([label]) => label);
169
+ expect(reached.sort()).toEqual([...reaches].sort());
170
+ });
171
+ }
172
+ });
173
+
174
+ describe("live fanout and replay agree", () => {
175
+ beforeEach(() => {
176
+ _resetStreamStateForTesting();
177
+ });
178
+
179
+ for (const { name, targeting, reaches } of CASES) {
180
+ test(`${name}: both paths deliver to the same subscribers`, async () => {
181
+ const expected = [...reaches].sort();
182
+ expect(await reachedLive(targeting)).toEqual(expected);
183
+ expect(reachedOnReplay(targeting)).toEqual(expected);
184
+ });
185
+ }
186
+ });
@@ -9,10 +9,8 @@ import { dirname, join } from "node:path";
9
9
  import { beforeEach, describe, expect, test } from "bun:test";
10
10
 
11
11
  import type { AssistantEventEnvelope } from "../api/index.js";
12
- import type {
13
- EventTargeting,
14
- ReplaySubscriber,
15
- } from "../runtime/assistant-stream-state.js";
12
+ import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
13
+ import type { SubscriberIdentity } from "../runtime/assistant-event-targeting.js";
16
14
  import {
17
15
  _peekStreamForTesting,
18
16
  _resetStreamStateForTesting,
@@ -103,7 +101,7 @@ describe("assistant-stream-state", () => {
103
101
  /** Targeted events stay in the ring so replay can filter them. */
104
102
 
105
103
  // GIVEN a targeting modifier
106
- const targeting: EventTargeting = {
104
+ const targeting: AssistantEventPublishOptions = {
107
105
  targetCapability: "host_bash",
108
106
  };
109
107
 
@@ -334,28 +332,28 @@ describe("assistant-stream-state", () => {
334
332
  });
335
333
 
336
334
  describe("getReplayWindow — targeting filter", () => {
337
- const MACOS_CLIENT: ReplaySubscriber = {
335
+ const MACOS_CLIENT: SubscriberIdentity = {
338
336
  type: "client",
339
337
  clientId: "mac-1",
340
338
  interfaceId: "macos",
341
339
  capabilities: ["host_bash", "host_file", "host_cu", "host_browser"],
342
340
  };
343
341
 
344
- const WEB_CLIENT: ReplaySubscriber = {
342
+ const WEB_CLIENT: SubscriberIdentity = {
345
343
  type: "client",
346
344
  clientId: "web-1",
347
345
  interfaceId: "web",
348
346
  capabilities: [],
349
347
  };
350
348
 
351
- const CHROME_EXT_CLIENT: ReplaySubscriber = {
349
+ const CHROME_EXT_CLIENT: SubscriberIdentity = {
352
350
  type: "client",
353
351
  clientId: "ext-1",
354
352
  interfaceId: "chrome-extension",
355
353
  capabilities: ["host_browser"],
356
354
  };
357
355
 
358
- const PROCESS_SUB: ReplaySubscriber = { type: "process" };
356
+ const PROCESS_SUB: SubscriberIdentity = { type: "process" };
359
357
 
360
358
  test("untargeted events are replayed to all subscriber types", () => {
361
359
  /** Events without targeting metadata go to everyone. */
@@ -98,6 +98,7 @@ import {
98
98
  clearConversations,
99
99
  setConversation,
100
100
  } from "../daemon/conversation-registry.js";
101
+ import type { TrustContext } from "../daemon/trust-context-types.js";
101
102
  import { getDb } from "../persistence/db-connection.js";
102
103
  import { initializeDb } from "../persistence/db-init.js";
103
104
  import * as deliveryCrud from "../persistence/delivery-crud.js";
@@ -318,7 +319,11 @@ describe("channel-retry-sweep", () => {
318
319
  }
319
320
  | undefined;
320
321
 
322
+ let capturedAuthor: TrustContext | undefined;
323
+ let capturedTrust: TrustContext | undefined;
321
324
  await sweepFailedEvents(async (conversationId, _content, options) => {
325
+ capturedAuthor = options?.author;
326
+ capturedTrust = options?.trustContext;
322
327
  capturedOptions = options as {
323
328
  trustContext?: {
324
329
  trustClass?: string;
@@ -345,6 +350,10 @@ describe("channel-retry-sweep", () => {
345
350
  "principal-1",
346
351
  );
347
352
  expect(capturedOptions?.isInteractive).toBe(c.expectedInteractive);
353
+ // The replay persists the sender's own message, so it names them as
354
+ // the row's author, exactly as live ingress does.
355
+ expect(capturedAuthor?.trustClass).toBe(c.trustClass);
356
+ expect(capturedAuthor).toEqual(capturedTrust);
348
357
 
349
358
  const db = getDb();
350
359
  const row = db
@@ -11,7 +11,15 @@
11
11
  * - When `backfillDm` throws, the turn proceeds without a crash and
12
12
  * nothing extra is persisted.
13
13
  */
14
- import { beforeEach, describe, expect, mock, test } from "bun:test";
14
+ import {
15
+ afterAll,
16
+ beforeEach,
17
+ describe,
18
+ expect,
19
+ mock,
20
+ spyOn,
21
+ test,
22
+ } from "bun:test";
15
23
 
16
24
  // ---------------------------------------------------------------------------
17
25
  // Mocks (must precede module imports under test)
@@ -68,6 +76,7 @@ mock.module("../messaging/providers/slack/backfill.js", () => ({
68
76
  backfillThread: () => backfillThreadMock(),
69
77
  }));
70
78
 
79
+ import * as inboundTrustReader from "../calls/inbound-trust-reader.js";
71
80
  import {
72
81
  loadRawConfig,
73
82
  saveRawConfig,
@@ -87,6 +96,15 @@ import {
87
96
 
88
97
  await initializeDb();
89
98
 
99
+ // Backfilled senders are classified by the gateway verdict; with no gateway
100
+ // in the test process the read reports unreachable unless a test says so.
101
+ // A spy rather than mock.module, so the stub does not leak into other files.
102
+ const readInboundTrustMock = spyOn(inboundTrustReader, "readInboundTrust");
103
+
104
+ afterAll(() => {
105
+ readInboundTrustMock.mockRestore();
106
+ });
107
+
90
108
  // ---------------------------------------------------------------------------
91
109
  // Helpers
92
110
  // ---------------------------------------------------------------------------
@@ -180,6 +198,7 @@ function readPersistedSlackRows(): Array<{
180
198
  provenanceSourceChannel: string | undefined;
181
199
  provenanceGuardianExternalUserId: string | undefined;
182
200
  provenanceRequesterIdentifier: string | undefined;
201
+ provenanceContactId: string | undefined;
183
202
  sentAt: number | undefined;
184
203
  createdAt: number;
185
204
  }> {
@@ -234,6 +253,10 @@ function readPersistedSlackRows(): Array<{
234
253
  typeof envelope.provenanceRequesterIdentifier === "string"
235
254
  ? envelope.provenanceRequesterIdentifier
236
255
  : undefined,
256
+ provenanceContactId:
257
+ typeof envelope.provenanceContactId === "string"
258
+ ? envelope.provenanceContactId
259
+ : undefined,
237
260
  sentAt:
238
261
  typeof envelope.sentAt === "number" ? envelope.sentAt : undefined,
239
262
  createdAt: row.createdAt,
@@ -279,6 +302,8 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
279
302
  backfillDmMock.mockReset();
280
303
  backfillDmMock.mockImplementation(async () => []);
281
304
  backfillThreadMock.mockReset();
305
+ readInboundTrustMock.mockReset();
306
+ readInboundTrustMock.mockImplementation(async () => ({ ok: false }));
282
307
  });
283
308
 
284
309
  test("first DM in cold conversation triggers backfill exactly once and persists history", async () => {
@@ -523,6 +548,63 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
523
548
  expect(botRow?.provenanceRequesterIdentifier).toBe("B_BOT");
524
549
  });
525
550
 
551
+ test("backfilled rows record the sender's gateway trust class and contact id", async () => {
552
+ readInboundTrustMock.mockImplementation(async (input) =>
553
+ input.actorExternalId === SLACK_DM_USER_ID
554
+ ? {
555
+ ok: true,
556
+ verdict: {
557
+ trustClass: "trusted_contact",
558
+ canonicalSenderId: SLACK_DM_USER_ID,
559
+ contactId: "contact-dm-user",
560
+ channelId: "channel-dm-user",
561
+ status: "active",
562
+ policy: "allow",
563
+ },
564
+ admissionPolicy: null,
565
+ }
566
+ : { ok: false },
567
+ );
568
+ backfillDmMock.mockImplementation(async () => [
569
+ makeBackfilledMessage({
570
+ id: "1700000000.000001",
571
+ text: "first from member",
572
+ }),
573
+ makeBackfilledMessage({
574
+ id: "1700000000.000002",
575
+ text: "assistant's own post",
576
+ sender: { id: "U_BOT", name: "assistant-bot" },
577
+ metadata: { isBot: true },
578
+ }),
579
+ makeBackfilledMessage({
580
+ id: "1700000000.000003",
581
+ text: "second from member",
582
+ }),
583
+ ]);
584
+
585
+ await handleChannelInbound(
586
+ buildDmRequest("live new DM"),
587
+ noopProcessMessage,
588
+ TEST_BEARER_TOKEN,
589
+ );
590
+
591
+ const rows = readPersistedSlackRows();
592
+ const memberRows = rows.filter((r) => r.role === "user");
593
+ expect(memberRows.map((r) => r.content).sort()).toEqual([
594
+ "first from member",
595
+ "second from member",
596
+ ]);
597
+ for (const row of memberRows) {
598
+ expect(row.provenanceTrustClass).toBe("trusted_contact");
599
+ expect(row.provenanceContactId).toBe("contact-dm-user");
600
+ }
601
+ const ownPost = rows.find((r) => r.role === "assistant");
602
+ expect(ownPost?.provenanceTrustClass).toBe("unknown");
603
+ expect(ownPost?.provenanceContactId).toBeUndefined();
604
+ // One read per distinct sender, the member's rows sharing theirs.
605
+ expect(readInboundTrustMock).toHaveBeenCalledTimes(2);
606
+ });
607
+
526
608
  test("skips Slack assistant new-thread placeholder during DM backfill", async () => {
527
609
  backfillDmMock.mockImplementation(async () => [
528
610
  makeBackfilledMessage({
@@ -662,7 +662,7 @@ describe("high/critical urgency channel force", () => {
662
662
  sourceChannel: "watcher" | "assistant_tool",
663
663
  ) {
664
664
  return emitNotificationSignal({
665
- sourceEventName: "watcher.escalation",
665
+ sourceEventName: "watcher.notification",
666
666
  sourceChannel,
667
667
  sourceContextId: "watch-1",
668
668
  routingIntent: "single_channel",
@@ -276,6 +276,11 @@ export function createOutboundSessionGuarded(
276
276
  }
277
277
  source.status = "revoked";
278
278
  source.updatedAt = Date.now();
279
+ // Like the gateway, the replacement keeps the claimed session's purpose.
280
+ return createOutboundSession({
281
+ ...createParams,
282
+ verificationPurpose: source.verificationPurpose,
283
+ });
279
284
  }
280
285
 
281
286
  return createOutboundSession(createParams);
@@ -0,0 +1,69 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { actorAuthorProvenance } from "../daemon/message-provenance.js";
4
+ import type { TrustContext } from "../daemon/trust-context-types.js";
5
+ import {
6
+ addMessage,
7
+ createConversation,
8
+ getMessageById,
9
+ parseMessageMetadata,
10
+ provenanceFromTrustContext,
11
+ } from "../persistence/conversation-crud.js";
12
+ import { getDb } from "../persistence/db-connection.js";
13
+ import { initializeDb } from "../persistence/db-init.js";
14
+
15
+ await initializeDb();
16
+
17
+ const ALICE: TrustContext = {
18
+ sourceChannel: "slack",
19
+ trustClass: "trusted_contact",
20
+ requesterIdentifier: "@alice",
21
+ requesterContactId: "contact-alice",
22
+ };
23
+
24
+ async function persistWith(
25
+ role: "user" | "assistant",
26
+ metadata: Record<string, unknown>,
27
+ ): Promise<ReturnType<typeof parseMessageMetadata>> {
28
+ const conversation = createConversation("provenance-contact-id");
29
+ const row = await addMessage(conversation.id, role, "hello", { metadata });
30
+ const stored = getMessageById(row.id, conversation.id);
31
+ return parseMessageMetadata(stored?.metadata ?? null);
32
+ }
33
+
34
+ describe("message provenance contact id", () => {
35
+ beforeEach(() => {
36
+ const db = getDb();
37
+ db.run(`DELETE FROM messages`);
38
+ db.run(`DELETE FROM conversations`);
39
+ });
40
+
41
+ test("a person's own row names them as its author", async () => {
42
+ const metadata = await persistWith("user", {
43
+ ...provenanceFromTrustContext(ALICE),
44
+ ...actorAuthorProvenance(ALICE),
45
+ });
46
+
47
+ expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
48
+ expect(metadata?.provenanceContactId).toBe("contact-alice");
49
+ });
50
+
51
+ test("a row written during their turn carries the turn's trust but no author", async () => {
52
+ // The assistant's replies, tool results, and notices are stamped with the
53
+ // turn's provenance alone.
54
+ const metadata = await persistWith(
55
+ "assistant",
56
+ provenanceFromTrustContext(ALICE),
57
+ );
58
+
59
+ expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
60
+ expect(metadata?.provenanceContactId).toBeUndefined();
61
+ });
62
+
63
+ test("an actor with no resolved contact names no author", () => {
64
+ expect(
65
+ actorAuthorProvenance({ sourceChannel: "slack", trustClass: "unknown" }),
66
+ ).toEqual({});
67
+ expect(actorAuthorProvenance(undefined)).toEqual({});
68
+ });
69
+ });
@@ -183,7 +183,7 @@ describe("TelegramAdapter", () => {
183
183
 
184
184
  await adapter.send(
185
185
  makePayload({
186
- sourceEventName: "watcher.escalation",
186
+ sourceEventName: "watcher.notification",
187
187
  copy: {
188
188
  title: " ",
189
189
  body: "",
@@ -191,7 +191,7 @@ describe("TelegramAdapter", () => {
191
191
  }),
192
192
  makeDestination(),
193
193
  );
194
- expect(sendCalls[2]?.text).toBe("watcher escalation");
194
+ expect(sendCalls[2]?.text).toBe("watcher notification");
195
195
  });
196
196
 
197
197
  // ── Access request inline keyboard tests ──────────────────────────────