@vellumai/assistant 0.12.2-dev.202609182015.188de8a → 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 (38) hide show
  1. package/ARCHITECTURE.md +8 -6
  2. package/docs/architecture/turn-actor.md +2 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-event-targeting.test.ts +186 -0
  5. package/src/__tests__/assistant-stream-state.test.ts +7 -9
  6. package/src/__tests__/channel-retry-sweep.test.ts +9 -0
  7. package/src/__tests__/dm-backfill.test.ts +83 -1
  8. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
  9. package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
  10. package/src/__tests__/process-message-display-content.test.ts +56 -0
  11. package/src/__tests__/reaction-persistence.test.ts +5 -0
  12. package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
  13. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
  14. package/src/__tests__/thread-backfill.test.ts +53 -0
  15. package/src/approvals/AGENTS.md +1 -1
  16. package/src/cli/commands/roadmap.help.ts +2 -3
  17. package/src/daemon/conversation-messaging.ts +19 -1
  18. package/src/daemon/message-provenance.ts +23 -0
  19. package/src/daemon/process-message.ts +13 -4
  20. package/src/persistence/conversation-crud.ts +8 -0
  21. package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
  22. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
  23. package/src/plugins/defaults/memory/tools.test.ts +24 -1
  24. package/src/plugins/defaults/memory/tools.ts +12 -1
  25. package/src/runtime/assistant-event-hub.ts +16 -86
  26. package/src/runtime/assistant-event-targeting.ts +79 -0
  27. package/src/runtime/assistant-stream-state.ts +13 -112
  28. package/src/runtime/channel-retry-sweep.ts +1 -0
  29. package/src/runtime/http-types.ts +6 -0
  30. package/src/runtime/routes/events-routes.ts +2 -2
  31. package/src/runtime/routes/inbound-message-handler.ts +35 -38
  32. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
  33. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -0
  34. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
  35. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
  36. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
  37. package/src/util/canonicalize-identity.test.ts +21 -0
  38. package/src/util/canonicalize-identity.ts +16 -0
@@ -73,6 +73,7 @@ import type {
73
73
  import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
74
74
  import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
75
75
  import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
76
+ import type { TrustContext } from "../daemon/trust-context-types.js";
76
77
 
77
78
  function createContext(): MessagingConversationContext {
78
79
  const channel: TurnChannelContext = {
@@ -223,3 +224,75 @@ describe("scripted-turn metadata persistence", () => {
223
224
  expect(lastUserMetadata().scripted).toBe(false);
224
225
  });
225
226
  });
227
+
228
+ describe("author contact id on a persisted user message", () => {
229
+ const sender: TrustContext = {
230
+ sourceChannel: "slack",
231
+ trustClass: "trusted_contact",
232
+ requesterContactId: "contact-alice",
233
+ };
234
+
235
+ beforeEach(() => {
236
+ addMessageCalls.length = 0;
237
+ });
238
+
239
+ test("a message relayed from the sender names them as its author", async () => {
240
+ await persistQueuedMessageBody(createContext(), {
241
+ content: "the export endpoint needs a scoped token",
242
+ requestId: "req-author-typed",
243
+ trustContext: sender,
244
+ author: sender,
245
+ });
246
+
247
+ expect(lastUserMetadata().provenanceContactId).toBe("contact-alice");
248
+ });
249
+
250
+ test("a row persisted under the sender's trust but not relayed from them names no author", async () => {
251
+ // Machine-authored rows (ACP and subagent notifications, pointer turns)
252
+ // share this writer and the conversation's trust; only an explicit
253
+ // author names one.
254
+ await persistQueuedMessageBody(createContext(), {
255
+ content: "the delegated task finished",
256
+ requestId: "req-author-machine",
257
+ trustContext: sender,
258
+ metadata: { acpNotification: { acpSessionId: "acp-session-1" } },
259
+ });
260
+
261
+ expect(lastUserMetadata().provenanceContactId).toBeUndefined();
262
+ });
263
+
264
+ test.each([
265
+ ["scripted", { scripted: true }],
266
+ ["automated", { metadata: { automated: true } }],
267
+ ["hidden", { metadata: { hidden: true } }],
268
+ [
269
+ "ACP notification",
270
+ { metadata: { acpNotification: { acpSessionId: "acp-session-1" } } },
271
+ ],
272
+ [
273
+ "subagent notification",
274
+ {
275
+ metadata: {
276
+ subagentNotification: {
277
+ subagentId: "subagent-1",
278
+ label: "Research",
279
+ status: "completed",
280
+ },
281
+ },
282
+ },
283
+ ],
284
+ ] as const)(
285
+ "a %s row speaks in their voice without naming them",
286
+ async (kind, extra) => {
287
+ await persistQueuedMessageBody(createContext(), {
288
+ content: "sent on the sender's behalf",
289
+ requestId: `req-author-${kind}`,
290
+ trustContext: sender,
291
+ author: sender,
292
+ ...extra,
293
+ });
294
+
295
+ expect(lastUserMetadata().provenanceContactId).toBeUndefined();
296
+ },
297
+ );
298
+ });
@@ -90,6 +90,7 @@ mock.module("../messaging/providers/slack/adapter.js", () => ({
90
90
 
91
91
  import { v4 as uuid } from "uuid";
92
92
 
93
+ import * as inboundTrustReader from "../calls/inbound-trust-reader.js";
93
94
  import {
94
95
  loadRawConfig,
95
96
  saveRawConfig,
@@ -142,10 +143,15 @@ installDefaultThreadPageMock();
142
143
  backfillThreadMock.mockResolvedValue([]);
143
144
  const backfillDmMock = spyOn(slackBackfill, "backfillDm");
144
145
  backfillDmMock.mockResolvedValue([]);
146
+ // Backfilled senders are classified by the gateway verdict. With no gateway in
147
+ // the test process the real read reports unreachable, so tests that need a
148
+ // verdict stub it per call.
149
+ const readInboundTrustSpy = spyOn(inboundTrustReader, "readInboundTrust");
145
150
 
146
151
  afterAll(() => {
147
152
  backfillThreadPageMock.mockRestore();
148
153
  backfillDmMock.mockRestore();
154
+ readInboundTrustSpy.mockRestore();
149
155
  });
150
156
 
151
157
  // ---------------------------------------------------------------------------
@@ -307,6 +313,7 @@ interface PersistedRow {
307
313
  provenanceSourceChannel: string | undefined;
308
314
  provenanceGuardianExternalUserId: string | undefined;
309
315
  provenanceRequesterIdentifier: string | undefined;
316
+ provenanceContactId: string | undefined;
310
317
  }
311
318
 
312
319
  const EXTERNAL_CONTENT_WRAPPER =
@@ -340,6 +347,7 @@ function readPersistedSlackRows(conversationId: string): PersistedRow[] {
340
347
  provenanceSourceChannel: undefined,
341
348
  provenanceGuardianExternalUserId: undefined,
342
349
  provenanceRequesterIdentifier: undefined,
350
+ provenanceContactId: undefined,
343
351
  };
344
352
  if (!row.metadata) {
345
353
  out.push(blank);
@@ -402,6 +410,10 @@ function readPersistedSlackRows(conversationId: string): PersistedRow[] {
402
410
  typeof envelope.provenanceRequesterIdentifier === "string"
403
411
  ? envelope.provenanceRequesterIdentifier
404
412
  : undefined,
413
+ provenanceContactId:
414
+ typeof envelope.provenanceContactId === "string"
415
+ ? envelope.provenanceContactId
416
+ : undefined,
405
417
  });
406
418
  }
407
419
  return out;
@@ -1149,6 +1161,47 @@ describe("triggerSlackThreadBackfillIfNeeded — gap detection and persistence",
1149
1161
  expect(persisted.provenanceRequesterIdentifier).toBe("U_ANITA");
1150
1162
  });
1151
1163
 
1164
+ test("backfilled thread rows record the sender's gateway trust class and contact id", async () => {
1165
+ const conv = createTestConversation();
1166
+ readInboundTrustSpy.mockImplementationOnce(async () => ({
1167
+ ok: true,
1168
+ verdict: {
1169
+ trustClass: "trusted_contact",
1170
+ canonicalSenderId: "U_ANITA",
1171
+ contactId: "contact-anita",
1172
+ channelId: "channel-anita",
1173
+ status: "active",
1174
+ policy: "allow",
1175
+ },
1176
+ admissionPolicy: null,
1177
+ }));
1178
+ backfillThreadMock.mockImplementation(async () => [
1179
+ makeBackfillMessage({
1180
+ id: "1234.0",
1181
+ text: "the export endpoint needs a scoped token",
1182
+ threadId: undefined,
1183
+ sender: { id: "U_ANITA", name: "Anita" },
1184
+ }),
1185
+ ]);
1186
+
1187
+ await triggerSlackThreadBackfillIfNeeded({
1188
+ conversationId: conv.id,
1189
+ channelId: SLACK_CHANNEL_ID,
1190
+ threadTs: "1234.0",
1191
+ guardianExternalUserId: "U_GUARDIAN",
1192
+ });
1193
+
1194
+ const [persisted] = readPersistedSlackRows(conv.id).filter(
1195
+ (p) => p.channelTs === "1234.0",
1196
+ );
1197
+ expect(persisted?.provenanceTrustClass).toBe("trusted_contact");
1198
+ expect(persisted?.provenanceContactId).toBe("contact-anita");
1199
+ expect(readInboundTrustSpy).toHaveBeenLastCalledWith({
1200
+ channelType: "slack",
1201
+ actorExternalId: "U_ANITA",
1202
+ });
1203
+ });
1204
+
1152
1205
  test("backfilled Slack timezone metadata derives timestamp and speaker fields", async () => {
1153
1206
  const conv = createTestConversation();
1154
1207
  setConfiguredUserTimezone("America/Denver");
@@ -21,7 +21,7 @@ Conversational guardian verification control-plane invocation is guardian-only.
21
21
 
22
22
  ## Memory Provenance Invariant
23
23
 
24
- All memory retrieval decisions must consider actor-role provenance. Untrusted actors (non-guardian, unverified_channel) must not receive memory recall results. This invariant is enforced in `indexer.ts` (write gate) and the memory plugin's `injectors.ts` (read gate), which admits personal-memory content only for a turn whose trust context passes `isPersonalMemoryAllowed`.
24
+ Memory access follows the acting actor's capabilities: `resolveCapabilities(trustClass).canAccessMemory`, which only `guardian` holds. An actor without it neither reads nor writes the guardian's memory. The memory plugin's `injectors.ts` admits personal-memory content only for a turn whose trust context passes `isPersonalMemoryAllowed`; the `recall`, `remember`, and `delete_memory_page` tools refuse such a turn (`plugins/defaults/memory/tools.ts`); and extraction from conversation messages runs only for guardian-provenance rows, or legacy rows with none (`plugins/defaults/memory/indexer.ts`).
25
25
 
26
26
  ## Guardian Privilege Isolation Invariant
27
27
 
@@ -25,7 +25,7 @@ no verb to post one.
25
25
  Examples:
26
26
  $ assistant roadmap list --sort upvotes --limit 10
27
27
  $ assistant roadmap get scheduled-messages
28
- $ assistant roadmap create --title "Add dark mode" --tag ui
28
+ $ assistant roadmap create --title "Add dark mode" --tag area-interface
29
29
  $ assistant roadmap upvote scheduled-messages`,
30
30
  subcommands: [
31
31
  {
@@ -97,7 +97,7 @@ accepted, since status is assigned by Vellum.
97
97
  Examples:
98
98
  $ assistant roadmap create --title "Add dark mode"
99
99
  $ assistant roadmap create --title "Recurring reminders" \\
100
- --description "Repeat a scheduled message weekly" --tag scheduling`,
100
+ --description "Repeat a scheduled message weekly" --tag area-assistant`,
101
101
  },
102
102
  {
103
103
  name: "update",
@@ -130,7 +130,6 @@ assistant does not own.
130
130
 
131
131
  Examples:
132
132
  $ assistant roadmap update dark-mode --description "Follow the OS setting"
133
- $ assistant roadmap update dark-mode --tag ui --tag theming
134
133
  $ assistant roadmap update dark-mode --clear-tags`,
135
134
  },
136
135
  {
@@ -63,7 +63,10 @@ import {
63
63
  syncMessageToDisk,
64
64
  updateMetaFile,
65
65
  } from "../persistence/conversation-disk-view.js";
66
- import { SIGHT_FRAME_ATTACHMENT_IDS_KEY } from "../persistence/conversation-types.js";
66
+ import {
67
+ isEchoSuppressedUserMessage,
68
+ SIGHT_FRAME_ATTACHMENT_IDS_KEY,
69
+ } from "../persistence/conversation-types.js";
67
70
  import {
68
71
  attachmentIdFragment,
69
72
  type ContentBlock,
@@ -76,6 +79,7 @@ import type { ConversationModeSessionCoordinator } from "./conversation-mode-ses
76
79
  import type { MessageQueue } from "./conversation-queue-manager.js";
77
80
  import type { SlackInboundMessageMetadata } from "./handlers/shared.js";
78
81
  import type { UserMessageAttachment } from "./message-protocol.js";
82
+ import { actorAuthorProvenance } from "./message-provenance.js";
79
83
  import type { ConversationTransportMetadata } from "./message-types/conversations.js";
80
84
  import { bestEffortModeSessionTracking } from "./mode-session-tracking.js";
81
85
  import {
@@ -964,6 +968,13 @@ export interface PersistMessageOptions {
964
968
  * persisting a message the current actor just sent.
965
969
  */
966
970
  trustContext?: TrustContext;
971
+ /**
972
+ * The person whose own inbound message this row records, passed only by a
973
+ * caller relaying one (channel ingress and its retry replay). It names the
974
+ * row's author (`actorAuthorProvenance`). Machine-authored callers omit it,
975
+ * so their rows name no author whatever the conversation's trust is.
976
+ */
977
+ author?: TrustContext;
967
978
  /**
968
979
  * Persist the row without indexing it (no memory segments, embeddings, or
969
980
  * lexical-index entry). For machine-authored prompts that must not enter
@@ -1320,6 +1331,13 @@ export async function persistQueuedMessageBody(
1320
1331
  const mergedMetadata = {
1321
1332
  ...metadataWithoutSlackInbound,
1322
1333
  ...provenance,
1334
+ // A scripted row, or one the repo classes as machine-injected (hidden,
1335
+ // ACP or subagent notification, background event), is not a person's
1336
+ // own words, so even a relayed author is not named on one.
1337
+ ...(resolvedScripted ||
1338
+ isEchoSuppressedUserMessage(metadataWithoutSlackInbound)
1339
+ ? {}
1340
+ : actorAuthorProvenance(options.author)),
1323
1341
  ...(turnCtx
1324
1342
  ? {
1325
1343
  userMessageChannel: turnCtx.userMessageChannel,
@@ -13,9 +13,13 @@
13
13
  * context compactor (image manifest) can apply the identical filter without
14
14
  * creating an import cycle through `conversation-lifecycle` ↔
15
15
  * `window-manager` ↔ `compactor`.
16
+ *
17
+ * It also owns the author field (`actorAuthorProvenance`), which says who
18
+ * wrote a row rather than whose turn wrote it.
16
19
  */
17
20
  import type { MessageRow } from "../persistence/conversation-crud.js";
18
21
  import { type TrustClass, trustClassSchema } from "../runtime/trust-class.js";
22
+ import type { TrustContext } from "./trust-context-types.js";
19
23
 
20
24
  export function parseProvenanceTrustClass(
21
25
  metadata: string | null,
@@ -56,3 +60,22 @@ export function filterMessagesForUntrustedActor(
56
60
  ): MessageRow[] {
57
61
  return messages.filter((m) => isRowVisibleToUntrustedActor(m.metadata));
58
62
  }
63
+
64
+ /** The persisted author field: the contact who wrote the row. */
65
+ export interface ActorAuthorProvenance {
66
+ provenanceContactId?: string;
67
+ }
68
+
69
+ /**
70
+ * Author fields for a row the trust context's actor wrote themselves: their
71
+ * own message or reaction. `provenanceFromTrustContext` describes the turn and
72
+ * is also stamped on the assistant's replies, tool results, and notices, so
73
+ * those rows must never carry these.
74
+ */
75
+ export function actorAuthorProvenance(
76
+ trustContext: TrustContext | undefined,
77
+ ): ActorAuthorProvenance {
78
+ return trustContext?.requesterContactId
79
+ ? { provenanceContactId: trustContext.requesterContactId }
80
+ : {};
81
+ }
@@ -67,12 +67,19 @@ import {
67
67
  preactivateHostProxySkills,
68
68
  shouldAttachHostProxyForCapability,
69
69
  } from "./host-proxy-preactivation.js";
70
+ import { actorAuthorProvenance } from "./message-provenance.js";
70
71
  import type { SubagentToolGateMode } from "./tool-setup-types.js";
71
- import { restingTrust } from "./trust-context-types.js";
72
+ import { restingTrust, type TrustContext } from "./trust-context-types.js";
72
73
 
73
74
  const log = getLogger("process-message");
74
75
 
75
76
  type ProcessMessageOptions = ConversationCreateOptions & {
77
+ /**
78
+ * The person whose own inbound message this turn persists, set only by
79
+ * channel ingress and its retry replay. Names the author on the persisted
80
+ * user row; see `PersistMessageOptions.author`.
81
+ */
82
+ author?: TrustContext;
76
83
  /** Per-turn observer for live agent-loop events. Does not replace SSE broadcast. */
77
84
  onEvent?: (msg: AssistantEvent) => void;
78
85
  /** IDs of user-uploaded attachments to resolve and include in the turn. */
@@ -539,7 +546,7 @@ export async function processMessage(
539
546
  ...(Object.keys(imageSourcePaths).length > 0 ? { imageSourcePaths } : {}),
540
547
  };
541
548
  const userMetaWithSlack = withChannelEnvelopes(
542
- serverChannelMeta,
549
+ { ...serverChannelMeta, ...actorAuthorProvenance(options?.author) },
543
550
  slackMeta,
544
551
  providerMeta,
545
552
  );
@@ -638,7 +645,7 @@ export async function processMessage(
638
645
  : {}),
639
646
  };
640
647
  const compactUserMeta = withChannelEnvelopes(
641
- compactChannelMeta,
648
+ { ...compactChannelMeta, ...actorAuthorProvenance(options?.author) },
642
649
  slackMeta,
643
650
  providerMeta,
644
651
  );
@@ -698,7 +705,7 @@ export async function processMessage(
698
705
  : {}),
699
706
  };
700
707
  const cleanUserMeta = withChannelEnvelopes(
701
- cleanChannelMeta,
708
+ { ...cleanChannelMeta, ...actorAuthorProvenance(options?.author) },
702
709
  slackMeta,
703
710
  providerMeta,
704
711
  );
@@ -744,6 +751,7 @@ export async function processMessage(
744
751
  requestId,
745
752
  metadata: persistMetadata,
746
753
  displayContent: options?.displayContent,
754
+ ...(options?.author ? { author: options.author } : {}),
747
755
  ...(options?.skipUserMessageIndexing ? { skipIndexing: true } : {}),
748
756
  ...(ingressKey ? { clientMessageId: ingressKey } : {}),
749
757
  },
@@ -825,6 +833,7 @@ export async function processMessageInBackground(
825
833
  requestId,
826
834
  metadata: persistMetadata,
827
835
  displayContent: options?.displayContent,
836
+ ...(options?.author ? { author: options.author } : {}),
828
837
  ...(ingressKey ? { clientMessageId: ingressKey } : {}),
829
838
  },
830
839
  );
@@ -402,6 +402,14 @@ export const messageMetadataSchema = z
402
402
  provenanceSourceChannel: channelIdSchema.optional(),
403
403
  provenanceGuardianExternalUserId: z.string().optional(),
404
404
  provenanceRequesterIdentifier: z.string().optional(),
405
+ /**
406
+ * Contact id of the person who wrote this row, from the gateway trust
407
+ * verdict at persist time. Stamped only on a person's own message or
408
+ * reaction (`actorAuthorProvenance`), never on rows the assistant writes
409
+ * during their turn: the other `provenance*` fields describe the turn,
410
+ * this one the author. Absent when the author resolved to no contact.
411
+ */
412
+ provenanceContactId: z.string().optional(),
405
413
  automated: z.boolean().optional(),
406
414
  /**
407
415
  * Transcript-suppression flag: the row is a machine signal (e.g. the
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The daily archive (`memory/archive/`) holds raw buffer entries only. Per-turn
3
+ * injection reads concept pages and never the archive, and text a pass
4
+ * composes or derives on a page is not in it. So nothing the consolidation
5
+ * agent reads may present the archive as making removal safe: that framing
6
+ * licenses deleting filed content that exists nowhere else.
7
+ *
8
+ * The check is sentence-level: no sentence may pair the archive with
9
+ * reassurance that removing content loses nothing. A sentence warning that
10
+ * removed text IS lost stays allowed.
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+
14
+ import { renderConsolidationPrompt } from "../substrate/prompts/consolidation.js";
15
+ import { deleteMemoryPageTool } from "../tools.js";
16
+
17
+ function archiveSafetyClaims(text: string): string[] {
18
+ return text
19
+ .split(/(?<=[.!?])\s+/)
20
+ .filter(
21
+ (sentence) =>
22
+ /archive/i.test(sentence) &&
23
+ /don.t worry|no need to worry|never los|nothing is lost|won.t lose|safe to (?:delete|remove|cut|drop)/i.test(
24
+ sentence,
25
+ ),
26
+ );
27
+ }
28
+
29
+ describe("the archive is never framed as making removal safe", () => {
30
+ test("neither consolidation article shape makes the claim", () => {
31
+ const v2 = renderConsolidationPrompt("May 1, 12:00 PM", {
32
+ includeCorePagesSection: false,
33
+ articleShape: "v2",
34
+ bufferEntries: "",
35
+ });
36
+ // The over-long-sections step carries the one legitimate archive
37
+ // sentence, so the v3 render includes it.
38
+ const v3 = renderConsolidationPrompt("May 1, 12:00 PM", {
39
+ includeCorePagesSection: true,
40
+ articleShape: "v3",
41
+ bufferEntries: "",
42
+ overlongSections: {
43
+ windowChars: 6000,
44
+ sections: [{ slug: "journal", title: "", chars: 7200 }],
45
+ },
46
+ });
47
+
48
+ expect(v3).toContain("the archive is a record");
49
+ expect(archiveSafetyClaims(v2)).toEqual([]);
50
+ expect(archiveSafetyClaims(v3)).toEqual([]);
51
+ });
52
+
53
+ test("a warning that removed text is lost is not the claim", () => {
54
+ expect(
55
+ archiveSafetyClaims(
56
+ "Text you cut from a page is lost to retrieval: the archive does not bring it back.",
57
+ ),
58
+ ).toEqual([]);
59
+ });
60
+
61
+ test("the delete_memory_page description does not make the claim", () => {
62
+ expect(archiveSafetyClaims(deleteMemoryPageTool.description)).toEqual([]);
63
+ });
64
+ });
@@ -312,7 +312,7 @@ Cutoff timestamp for this run: \`${CUTOFF_PLACEHOLDER}\`. The entries for this p
312
312
  - Updated \`memory/threads.md\` (≤10000 chars)
313
313
  - Updated \`edges:\` frontmatter in any pages whose outgoing links changed
314
314
 
315
- How retrieval works: high-activation pages are loaded at the start of each turn. Activations spread along **directed** edges from source to target — activating A pulls in the pages A points at, but not the reverse. The immutable archive retains the entire buffer forever, so don't worry about losing information.
315
+ How retrieval works: high-activation pages are loaded at the start of each turn. Activations spread along **directed** edges from source to target: activating A pulls in the pages A points at, but not the reverse.
316
316
 
317
317
  ---
318
318
 
@@ -724,7 +724,7 @@ Three consequences:
724
724
 
725
725
  1. **The lead IS the card, and the fallback.** Write every lead as a standalone orientation: what this article is, the one or two facts that identify it, where it sits. If the lead only makes sense after reading the sections, the card is useless and so is the fallback. One to three short paragraphs.
726
726
  2. **Section names are navigation.** They appear on the selector's card as the table of contents and head every injected section. Name sections so future-you can tell from the name alone whether the answer lives there.
727
- 3. **Sections are the unit of growth and retrieval.** A fact filed in the right section of the right article is findable and arrives in context with its section (kept under the 6000-character chunk window, so the section travels whole); a fact buried mid-paragraph in an overlong lead is not. The immutable archive retains the entire buffer forever, so don't worry about losing information.
727
+ 3. **Sections are the unit of growth and retrieval.** A fact filed in the right section of the right article is findable and arrives in context with its section (kept under the 6000-character chunk window, so the section travels whole); a fact buried mid-paragraph in an overlong lead is not.
728
728
 
729
729
  ---
730
730
 
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import {
@@ -305,6 +305,29 @@ describe("rememberTool.execute — buffer writes", () => {
305
305
  });
306
306
  });
307
307
 
308
+ describe("rememberTool.execute — memory access", () => {
309
+ test.each(["trusted_contact", "unverified_contact", "unknown"] as const)(
310
+ "refuses a %s write without touching the memory buffer",
311
+ async (trustClass) => {
312
+ const fact = `a claim made on a ${trustClass} turn`;
313
+ const result = await rememberTool.execute(
314
+ { content: fact, finish_turn: true },
315
+ makeContext({ trustClass }),
316
+ );
317
+
318
+ expect(result.isError).toBe(true);
319
+ expect(result.content).toContain("Not saved");
320
+ // The turn continues so the model can relay the refusal.
321
+ expect(result.yieldToUser).toBeUndefined();
322
+ const bufferPath = join(tmpWorkspace, "memory", "buffer.md");
323
+ const buffer = existsSync(bufferPath)
324
+ ? readFileSync(bufferPath, "utf-8")
325
+ : "";
326
+ expect(buffer).not.toContain(fact);
327
+ },
328
+ );
329
+ });
330
+
308
331
  describe("rememberTool.execute — batch (array) content", () => {
309
332
  beforeEach(() => {
310
333
  enqueueCalls.length = 0;
@@ -55,6 +55,17 @@ export const rememberTool = {
55
55
  const typedInput = input as unknown as RememberInput;
56
56
  // The append below writes the memory buffer, so a cancelled turn stops here.
57
57
  throwIfCancelled(context);
58
+ if (!resolveCapabilities(context.trustClass).canAccessMemory) {
59
+ // No yield even when finish_turn is set: the model has to see this, so
60
+ // it never tells someone a fact was saved when it was not. An error, not
61
+ // a quiet no-op: a non-error `remember` result reads as a durable write to
62
+ // `memory-run-evidence`.
63
+ return {
64
+ content:
65
+ "Not saved: remember writes the guardian's long-term memory, which is only available on the guardian's own turns. Retrying will not help. Tell the person only if they asked you to remember this or you said you would.",
66
+ isError: true,
67
+ };
68
+ }
58
69
  const result = handleRemember(
59
70
  typedInput,
60
71
  context.conversationId,
@@ -127,7 +138,7 @@ export const recallTool = {
127
138
  export const deleteMemoryPageTool = {
128
139
  name: "delete_memory_page",
129
140
  description:
130
- "Delete one concept page from your memory wiki, addressed by slug (its path under `memory/concepts/` minus `.md`, e.g. `alice`, `people/alice`, `procs/git-flow`). Use during a consolidation/maintenance pass to retire a page you merged into another, renamed (write the new page, then delete the old slug), or dropped as a dead stub. Only concept pages can be deleted: the index files (`recent.md`, `essentials.md`, `threads.md`) are rewritten with file_write/file_edit, never deleted, and `buffer.md` is never written by you at all; the runtime removes the entries it handed you once the pass completes. Idempotent: deleting a slug that is already gone is not an error. The immutable archive retains buffer history, so removing a page never loses source facts.",
141
+ "Delete one concept page from your memory wiki, addressed by slug (its path under `memory/concepts/` minus `.md`, e.g. `alice`, `people/alice`, `procs/git-flow`). Use during a consolidation/maintenance pass to retire a page you merged into another, renamed (write the new page, then delete the old slug), or dropped as a dead stub. Only concept pages can be deleted: the index files (`recent.md`, `essentials.md`, `threads.md`) are rewritten with file_write/file_edit, never deleted, and `buffer.md` is never written by you at all; the runtime removes the entries it handed you once the pass completes. Idempotent: deleting a slug that is already gone is not an error.",
131
142
  category: "memory",
132
143
  executionTarget: "sandbox",
133
144
  defaultRiskLevel: RiskLevel.Low,
@@ -53,6 +53,7 @@ import { appendEventToStream } from "../signals/event-stream.js";
53
53
  import { getLogger } from "../util/logger.js";
54
54
  import { buildAssistantEvent } from "./assistant-event.js";
55
55
  import type { AssistantEventPublishOptions } from "./assistant-event-publish-options.js";
56
+ import { matchesTargeting } from "./assistant-event-targeting.js";
56
57
  import { stampAndBuffer } from "./assistant-stream-state.js";
57
58
  import { isMainDaemonProcess } from "./process-role.js";
58
59
 
@@ -323,24 +324,14 @@ export class AssistantEventHub {
323
324
  * Publish an event to all matching subscribers.
324
325
  *
325
326
  * Matching rules:
326
- * - if `excludeClientId` is set, the subscriber with that clientId is
327
- * skipped regardless of every other rule (self-echo suppression — the
328
- * client that originated the mutation does not receive its own
329
- * invalidation back through the hub).
330
- * - if `targetClientId` is set, deliver only to the subscriber with that
331
- * clientId, bypassing the conversation-id filter entirely (the web-origin
332
- * event's conversationId differs from the macOS client's subscribed
333
- * conversation).
334
- * - if `filter.conversationId` is set (and `targetClientId` is not), the
335
- * `event.conversationId` must equal it
336
- * - if `targetCapability` is set, only subscribers whose capabilities include
337
- * it receive the event; untargeted events go to all
338
- * - if `targetInterfaceId` is set, only client subscribers whose
339
- * `interfaceId` matches receive the event; process subscribers and
340
- * non-matching clients are skipped.
341
- * - if `targetActorPrincipalId` is set, only client subscribers whose
342
- * verified `actorPrincipalId` equals it receive the event; process
343
- * subscribers and clients without a principal are skipped.
327
+ * - the event's targeting (`excludeClientId`, `targetInterfaceId`,
328
+ * `targetActorPrincipalId`, `targetClientId`, `targetCapability`) must
329
+ * admit the subscriber, per {@link matchesTargeting}, the same check
330
+ * replay applies
331
+ * - a subscriber's `filter.conversationId` must equal `event.conversationId`,
332
+ * unless the event names a client with `targetClientId` (a web-origin
333
+ * event's conversationId can differ from the conversation the targeted
334
+ * macOS client subscribed to)
344
335
  *
345
336
  * Fanout is isolated: a throwing or rejecting subscriber does not abort
346
337
  * delivery to remaining subscribers.
@@ -365,11 +356,6 @@ export class AssistantEventHub {
365
356
  }
366
357
  }
367
358
 
368
- const targetCapability = options?.targetCapability;
369
- const targetClientId = options?.targetClientId;
370
- const targetInterfaceId = options?.targetInterfaceId;
371
- const targetActorPrincipalId = options?.targetActorPrincipalId;
372
- const excludeClientId = options?.excludeClientId;
373
359
  const snapshot = Array.from(this.subscribers);
374
360
  const errors: unknown[] = [];
375
361
 
@@ -377,74 +363,18 @@ export class AssistantEventHub {
377
363
  if (!entry.active) {
378
364
  continue;
379
365
  }
380
-
381
- // Self-echo suppression: the originating client never receives the
382
- // event back. Checked before every other rule so it composes with
383
- // both targeted and untargeted broadcasts.
366
+ if (!matchesTargeting(options, entry)) {
367
+ continue;
368
+ }
384
369
  if (
385
- excludeClientId != null &&
386
- entry.type === "client" &&
387
- entry.clientId === excludeClientId
370
+ options?.targetClientId == null &&
371
+ event.conversationId != null &&
372
+ entry.filter.conversationId != null &&
373
+ entry.filter.conversationId !== event.conversationId
388
374
  ) {
389
375
  continue;
390
376
  }
391
377
 
392
- // Interface targeting: skip any subscriber that is not a client of
393
- // the requested interface. Composes with `targetClientId` and
394
- // `targetCapability` below.
395
- if (targetInterfaceId != null) {
396
- if (
397
- entry.type !== "client" ||
398
- entry.interfaceId !== targetInterfaceId
399
- ) {
400
- continue;
401
- }
402
- }
403
-
404
- // Principal targeting: an event scoped to one person reaches only that
405
- // person's own connections. Composes with every other rule.
406
- if (targetActorPrincipalId != null) {
407
- if (
408
- entry.type !== "client" ||
409
- entry.actorPrincipalId !== targetActorPrincipalId
410
- ) {
411
- continue;
412
- }
413
- }
414
-
415
- if (targetClientId != null) {
416
- // Targeted: bypass conversation filter, deliver only to the named client.
417
- if (entry.type !== "client" || entry.clientId !== targetClientId) {
418
- continue;
419
- }
420
- if (
421
- targetCapability != null &&
422
- !entry.capabilities.includes(targetCapability)
423
- ) {
424
- continue;
425
- }
426
- } else {
427
- // Untargeted: existing conversation-scoped + capability logic.
428
- if (
429
- event.conversationId != null &&
430
- entry.filter.conversationId != null &&
431
- entry.filter.conversationId !== event.conversationId
432
- ) {
433
- continue;
434
- }
435
-
436
- // Capability targeting: targeted events only go to subscribers that
437
- // declare the required capability.
438
- if (targetCapability != null) {
439
- if (
440
- entry.type !== "client" ||
441
- !entry.capabilities.includes(targetCapability)
442
- ) {
443
- continue;
444
- }
445
- }
446
- }
447
-
448
378
  try {
449
379
  await entry.callback(event);
450
380
  } catch (err) {