@vellumai/assistant 0.10.4-dev.202607012136.5ef7865 → 0.10.4-dev.202607012335.d6d6958

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/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  2. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +16 -3
  3. package/openapi.yaml +80 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/approval-interception-trust-gates.test.ts +148 -3
  6. package/src/__tests__/context-search-conversations-source.test.ts +33 -58
  7. package/src/__tests__/non-member-access-request.test.ts +224 -1
  8. package/src/__tests__/persona-resolver.test.ts +56 -2
  9. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  10. package/src/__tests__/slack-inbound-verification.test.ts +36 -0
  11. package/src/__tests__/trust-context-guards.test.ts +21 -10
  12. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
  13. package/src/__tests__/workspace-migration-121-seed-default-user-guardrails.test.ts +118 -0
  14. package/src/approvals/guardian-request-resolvers.ts +21 -1
  15. package/src/calls/relay-server.ts +10 -0
  16. package/src/channels/channel-verification-sessions.ts +6 -1
  17. package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
  18. package/src/cli/lib/toggle-plugin.ts +21 -2
  19. package/src/config/assistant-feature-flags.ts +0 -22
  20. package/src/config/feature-flag-registry.json +0 -8
  21. package/src/contacts/__tests__/member-write-relay.test.ts +57 -1
  22. package/src/contacts/member-write-relay.ts +41 -0
  23. package/src/daemon/disk-pressure-policy.ts +2 -1
  24. package/src/daemon/message-provenance.ts +12 -14
  25. package/src/daemon/message-types/sync.ts +1 -0
  26. package/src/persistence/__tests__/conversation-queries-search.test.ts +37 -41
  27. package/src/persistence/conversation-queries.ts +37 -88
  28. package/src/persistence/conversation-search-lexical.ts +1 -1
  29. package/src/persistence/job-handlers/cleanup.ts +3 -4
  30. package/src/persistence/migrations/312-drop-inbox-conversation-state-table.ts +15 -0
  31. package/src/persistence/schema/contacts.ts +0 -28
  32. package/src/persistence/steps.ts +2 -0
  33. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +0 -20
  34. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +22 -62
  35. package/src/plugins/defaults/turn-context/unified-turn-context.ts +8 -0
  36. package/src/prompts/__tests__/system-prompt.test.ts +128 -1
  37. package/src/prompts/persona-resolver.ts +13 -9
  38. package/src/prompts/system-prompt.ts +24 -1
  39. package/src/prompts/templates/users/default.md +35 -0
  40. package/src/runtime/__tests__/channel-verification-service.test.ts +2 -2
  41. package/src/runtime/access-request-helper.ts +71 -2
  42. package/src/runtime/channel-verification-service.ts +3 -1
  43. package/src/runtime/local-actor-identity.test.ts +38 -0
  44. package/src/runtime/local-actor-identity.ts +14 -0
  45. package/src/runtime/routes/__tests__/plugins-routes.test.ts +360 -3
  46. package/src/runtime/routes/app-management-routes.ts +4 -7
  47. package/src/runtime/routes/guardian-approval-interception.ts +51 -0
  48. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +215 -0
  49. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +140 -27
  50. package/src/runtime/routes/inbound-stages/background-dispatch.ts +2 -3
  51. package/src/runtime/routes/inbound-stages/escalation-intercept.test.ts +158 -0
  52. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +22 -1
  53. package/src/runtime/routes/plugins-routes.ts +149 -1
  54. package/src/runtime/sync/resource-sync-events.ts +15 -0
  55. package/src/runtime/tool-grant-request-helper.ts +17 -1
  56. package/src/runtime/trust-class.test.ts +141 -0
  57. package/src/runtime/trust-class.ts +88 -0
  58. package/src/workspace/migrations/121-seed-default-user-guardrails.ts +82 -0
  59. package/src/workspace/migrations/registry.ts +2 -0
  60. package/src/config/__tests__/messages-search-backend.test.ts +0 -77
@@ -77,6 +77,7 @@ export type { AdmissionPolicy } from "./admission-policy-contract.js";
77
77
 
78
78
  // Trust verdict contract (gateway → daemon) — Zod schemas + derived types
79
79
  export {
80
+ isTrustClass,
80
81
  makeResolutionFailedVerdict,
81
82
  ResolveInboundTrustRequestSchema,
82
83
  TRUST_CLASS_VALUES,
@@ -32,6 +32,18 @@ export const TrustClassSchema = z.enum(TRUST_CLASS_VALUES);
32
32
 
33
33
  export type TrustClass = (typeof TRUST_CLASS_VALUES)[number];
34
34
 
35
+ const TRUST_CLASS_SET: ReadonlySet<string> = new Set(TRUST_CLASS_VALUES);
36
+
37
+ /**
38
+ * Type guard for wire-sourced trust classes. Consumers receive verdicts over
39
+ * IPC/HTTP, so a field statically typed {@link TrustClass} can still carry an
40
+ * out-of-contract value (version skew, malformed payload); this narrows it
41
+ * safely without casting.
42
+ */
43
+ export function isTrustClass(value: string): value is TrustClass {
44
+ return TRUST_CLASS_SET.has(value);
45
+ }
46
+
35
47
  /**
36
48
  * Per-actor trust verdict resolved by the gateway from its ACL DB. ACL +
37
49
  * identity keys + minimal labels only. Guardian binding and member
@@ -42,9 +54,10 @@ export const TrustVerdictSchema = z.object({
42
54
  trustClass: TrustClassSchema,
43
55
  canonicalSenderId: z.string().nullable(),
44
56
 
45
- // Present+true ⇒ gateway attempted resolution but failed (DB error);
46
- // consumer treats it as "could not vouch", distinct from a real `unknown`
47
- // stranger.
57
+ // Present+true ⇒ gateway attempted resolution but could not produce a
58
+ // usable verdict (DB error, or a sender who maps to the guardian contact
59
+ // whose principal is unresolved); consumer treats it as "could not vouch",
60
+ // distinct from a real `unknown` stranger.
48
61
  resolutionFailed: z.boolean().optional(),
49
62
 
50
63
  // Guardian binding — present only when a guardian binding matches.
package/openapi.yaml CHANGED
@@ -21119,6 +21119,11 @@ paths:
21119
21119
  name:
21120
21120
  type: string
21121
21121
  description: Display name. Equal to `id` today.
21122
+ enabled:
21123
+ type: boolean
21124
+ description:
21125
+ Whether the plugin is active in this workspace. `false` when a `.disabled` sentinel is present under its
21126
+ directory; `true` otherwise.
21122
21127
  description:
21123
21128
  anyOf:
21124
21129
  - type: string
@@ -21147,6 +21152,7 @@ paths:
21147
21152
  required:
21148
21153
  - id
21149
21154
  - name
21155
+ - enabled
21150
21156
  - description
21151
21157
  - version
21152
21158
  additionalProperties: false
@@ -21430,6 +21436,80 @@ paths:
21430
21436
  description: The install recorded no commit or no fingerprint to re-materialize and verify a baseline from.
21431
21437
  "503":
21432
21438
  description: The plugin source (GitHub) was temporarily unavailable; the diff is retryable.
21439
+ /v1/plugins/{name}/disable:
21440
+ post:
21441
+ operationId: plugins_by_name_disable_post
21442
+ summary: Disable a plugin
21443
+ description:
21444
+ Disable a plugin in this workspace by dropping a `.disabled` sentinel, mirroring the CLI's `assistant
21445
+ plugins disable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
21446
+ restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
21447
+ refetch `GET /v1/plugins`. An already-disabled plugin returns 409; a user plugin with no directory returns 404
21448
+ (default plugins are stubbed on demand via the `default-` prefix); a malformed name returns 400.
21449
+ tags:
21450
+ - plugins
21451
+ parameters:
21452
+ - name: name
21453
+ in: path
21454
+ required: true
21455
+ schema:
21456
+ type: string
21457
+ responses:
21458
+ "200":
21459
+ description: Successful response
21460
+ content:
21461
+ application/json:
21462
+ schema:
21463
+ type: object
21464
+ properties:
21465
+ ok:
21466
+ type: boolean
21467
+ required:
21468
+ - ok
21469
+ additionalProperties: false
21470
+ "400":
21471
+ description: The plugin name failed validation (not kebab-case alphanumerics).
21472
+ "404":
21473
+ description: No plugin directory exists with the given name (user plugins must already be installed).
21474
+ "409":
21475
+ description: The plugin is already disabled.
21476
+ /v1/plugins/{name}/enable:
21477
+ post:
21478
+ operationId: plugins_by_name_enable_post
21479
+ summary: Enable a plugin
21480
+ description:
21481
+ Enable a plugin in this workspace by removing its `.disabled` sentinel, mirroring the CLI's `assistant
21482
+ plugins enable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
21483
+ restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
21484
+ refetch `GET /v1/plugins`. An already-enabled plugin returns 409; a name with no plugin directory returns 404
21485
+ (prefix a default plugin with `default-`); a malformed name returns 400.
21486
+ tags:
21487
+ - plugins
21488
+ parameters:
21489
+ - name: name
21490
+ in: path
21491
+ required: true
21492
+ schema:
21493
+ type: string
21494
+ responses:
21495
+ "200":
21496
+ description: Successful response
21497
+ content:
21498
+ application/json:
21499
+ schema:
21500
+ type: object
21501
+ properties:
21502
+ ok:
21503
+ type: boolean
21504
+ required:
21505
+ - ok
21506
+ additionalProperties: false
21507
+ "400":
21508
+ description: The plugin name failed validation (not kebab-case alphanumerics).
21509
+ "404":
21510
+ description: No plugin directory exists with the given name.
21511
+ "409":
21512
+ description: The plugin is already enabled.
21433
21513
  /v1/plugins/{name}/inspect:
21434
21514
  get:
21435
21515
  operationId: plugins_by_name_inspect_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607012136.5ef7865",
3
+ "version": "0.10.4-dev.202607012335.d6d6958",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,8 +24,14 @@ mock.module("../daemon/conversation-registry.js", () => ({
24
24
  findConversation: (id: string) => _conversationMocks.get(id),
25
25
  }));
26
26
 
27
+ // Anchored guardian principal, driven per-test to exercise the guardian
28
+ // principal gate (undefined = anchor unresolvable).
29
+ let _anchorPrincipalId: string | undefined;
30
+ mock.module("../runtime/local-actor-identity.js", () => ({
31
+ findLocalGuardianPrincipalId: async () => _anchorPrincipalId,
32
+ }));
33
+
27
34
  import type { Conversation } from "../daemon/conversation.js";
28
- import type { TrustContext } from "../daemon/trust-context.js";
29
35
  import { initializeDb } from "../persistence/db-init.js";
30
36
  import * as approvalMessageComposer from "../runtime/approval-message-composer.js";
31
37
  import * as gatewayClient from "../runtime/gateway-client.js";
@@ -76,6 +82,7 @@ describe("approval interception trust-class gates", () => {
76
82
 
77
83
  beforeEach(() => {
78
84
  pendingInteractions.clear();
85
+ _anchorPrincipalId = undefined;
79
86
  deliverSpy = spyOn(gatewayClient, "deliverChannelReply").mockResolvedValue({
80
87
  ok: true,
81
88
  });
@@ -105,7 +112,7 @@ describe("approval interception trust-class gates", () => {
105
112
  trustClass: "unknown",
106
113
  requesterExternalUserId: "intruder-user-1",
107
114
  guardianExternalUserId: "guardian-1",
108
- } as TrustContext,
115
+ },
109
116
  assistantId: ASSISTANT_ID,
110
117
  });
111
118
 
@@ -117,6 +124,144 @@ describe("approval interception trust-class gates", () => {
117
124
  composeSpy.mockRestore();
118
125
  });
119
126
 
127
+ test("guardian apr: callback with a principal matching the anchor applies the decision", async () => {
128
+ _anchorPrincipalId = "guardian-principal-1";
129
+ const sessionMock = registerPendingInteraction(
130
+ "req-guardian-apply-1",
131
+ CONVERSATION_ID,
132
+ TOOL_NAME,
133
+ TOOL_INPUT,
134
+ );
135
+
136
+ const result = await handleApprovalInterception({
137
+ conversationId: CONVERSATION_ID,
138
+ callbackData: "apr:req-guardian-apply-1:approve_once",
139
+ content: "",
140
+ conversationExternalId: REQUESTER_CHAT,
141
+ sourceChannel: "telegram",
142
+ actorExternalId: "guardian-user-1",
143
+ replyCallbackUrl: "https://gateway.test/deliver",
144
+ trustCtx: {
145
+ sourceChannel: "telegram",
146
+ trustClass: "guardian",
147
+ requesterExternalUserId: "guardian-user-1",
148
+ guardianExternalUserId: "guardian-user-1",
149
+ guardianPrincipalId: "guardian-principal-1",
150
+ },
151
+ assistantId: ASSISTANT_ID,
152
+ });
153
+
154
+ expect(result.handled).toBe(true);
155
+ expect(result.type).toBe("decision_applied");
156
+ expect(sessionMock).toHaveBeenCalled();
157
+ });
158
+
159
+ test("guardian apr: callback with a principal NOT matching the anchor is rejected before any decision", async () => {
160
+ _anchorPrincipalId = "the-real-guardian-principal";
161
+ const sessionMock = registerPendingInteraction(
162
+ "req-guardian-mismatch-1",
163
+ CONVERSATION_ID,
164
+ TOOL_NAME,
165
+ TOOL_INPUT,
166
+ );
167
+
168
+ const result = await handleApprovalInterception({
169
+ conversationId: CONVERSATION_ID,
170
+ callbackData: "apr:req-guardian-mismatch-1:approve_once",
171
+ content: "",
172
+ conversationExternalId: REQUESTER_CHAT,
173
+ sourceChannel: "telegram",
174
+ actorExternalId: "stale-guardian-user",
175
+ replyCallbackUrl: "https://gateway.test/deliver",
176
+ trustCtx: {
177
+ sourceChannel: "telegram",
178
+ trustClass: "guardian",
179
+ requesterExternalUserId: "stale-guardian-user",
180
+ guardianExternalUserId: "stale-guardian-user",
181
+ guardianPrincipalId: "some-other-principal",
182
+ },
183
+ assistantId: ASSISTANT_ID,
184
+ });
185
+
186
+ expect(result.handled).toBe(true);
187
+ expect(result.type).toBe("stale_ignored");
188
+ expect(sessionMock).not.toHaveBeenCalled();
189
+ // Generic failure copy — no oracle about pending requests or permission.
190
+ expect(deliverSpy).toHaveBeenCalledWith(
191
+ "https://gateway.test/deliver",
192
+ expect.objectContaining({
193
+ text: "Sorry, I couldn't process that. Please try again.",
194
+ }),
195
+ );
196
+ });
197
+
198
+ test("guardian without a resolved acting principal is rejected before any decision", async () => {
199
+ // Address-only guardian classification (e.g. a binding row with a null
200
+ // principal) must not authorize decisions.
201
+ _anchorPrincipalId = "the-real-guardian-principal";
202
+ const sessionMock = registerPendingInteraction(
203
+ "req-guardian-no-principal-1",
204
+ CONVERSATION_ID,
205
+ TOOL_NAME,
206
+ TOOL_INPUT,
207
+ );
208
+
209
+ const result = await handleApprovalInterception({
210
+ conversationId: CONVERSATION_ID,
211
+ callbackData: "apr:req-guardian-no-principal-1:approve_once",
212
+ content: "",
213
+ conversationExternalId: REQUESTER_CHAT,
214
+ sourceChannel: "telegram",
215
+ actorExternalId: "guardian-user-1",
216
+ replyCallbackUrl: "https://gateway.test/deliver",
217
+ trustCtx: {
218
+ sourceChannel: "telegram",
219
+ trustClass: "guardian",
220
+ requesterExternalUserId: "guardian-user-1",
221
+ guardianExternalUserId: "guardian-user-1",
222
+ },
223
+ assistantId: ASSISTANT_ID,
224
+ });
225
+
226
+ expect(result.handled).toBe(true);
227
+ expect(result.type).toBe("stale_ignored");
228
+ expect(sessionMock).not.toHaveBeenCalled();
229
+ });
230
+
231
+ test("guardian decision defers to the verdict when the anchor read is unresolvable", async () => {
232
+ // Transient gateway miss on the anchor read: the gateway-stamped verdict
233
+ // (which already classified the actor guardian by principal) wins.
234
+ _anchorPrincipalId = undefined;
235
+ const sessionMock = registerPendingInteraction(
236
+ "req-guardian-anchor-miss-1",
237
+ CONVERSATION_ID,
238
+ TOOL_NAME,
239
+ TOOL_INPUT,
240
+ );
241
+
242
+ const result = await handleApprovalInterception({
243
+ conversationId: CONVERSATION_ID,
244
+ callbackData: "apr:req-guardian-anchor-miss-1:approve_once",
245
+ content: "",
246
+ conversationExternalId: REQUESTER_CHAT,
247
+ sourceChannel: "telegram",
248
+ actorExternalId: "guardian-user-1",
249
+ replyCallbackUrl: "https://gateway.test/deliver",
250
+ trustCtx: {
251
+ sourceChannel: "telegram",
252
+ trustClass: "guardian",
253
+ requesterExternalUserId: "guardian-user-1",
254
+ guardianExternalUserId: "guardian-user-1",
255
+ guardianPrincipalId: "guardian-principal-1",
256
+ },
257
+ assistantId: ASSISTANT_ID,
258
+ });
259
+
260
+ expect(result.handled).toBe(true);
261
+ expect(result.type).toBe("decision_applied");
262
+ expect(sessionMock).toHaveBeenCalled();
263
+ });
264
+
120
265
  test("unverified sender (no identity) auto-denies pending approval", async () => {
121
266
  const sessionMock = registerPendingInteraction(
122
267
  "req-unknown-auto-deny-1",
@@ -135,7 +280,7 @@ describe("approval interception trust-class gates", () => {
135
280
  trustCtx: {
136
281
  sourceChannel: "telegram",
137
282
  trustClass: "unknown",
138
- } as TrustContext,
283
+ },
139
284
  assistantId: ASSISTANT_ID,
140
285
  });
141
286
 
@@ -2,13 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
4
4
  import type { MessageLexicalSearchResult } from "../persistence/embeddings/messages-lexical-index.js";
5
- import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
6
5
 
7
6
  // Mutable stand-in for the Qdrant lexical candidate helper. The default
8
7
  // throws so any qdrant-path test that forgets to set an implementation fails
9
8
  // loudly rather than silently exercising an empty candidate set. FTS-path
10
- // tests never reach it (the flag defaults to fts5), so its value is irrelevant
11
- // there.
9
+ // tests never reach it (fts5 is used until the backfill completes), so its
10
+ // value is irrelevant there.
12
11
  let lexicalMockImpl: (
13
12
  query: string,
14
13
  limit: number,
@@ -99,7 +98,10 @@ describe("searchConversationSource", () => {
99
98
  });
100
99
  });
101
100
 
102
- test("uses LIKE fallback for short and non-ASCII queries", async () => {
101
+ test("returns no evidence for short and non-ASCII queries (content matching is index-only)", async () => {
102
+ // Short/punctuation-only and CJK queries produce no usable ≥2-char match
103
+ // shape. There is no content-scan fallback, so such queries yield no
104
+ // conversation evidence even when an exact substring exists.
103
105
  await seedConversation({
104
106
  title: "C++ notes",
105
107
  role: "user",
@@ -117,12 +119,8 @@ describe("searchConversationSource", () => {
117
119
  5,
118
120
  );
119
121
 
120
- expect(shortResult.evidence.map((item) => item.title)).toEqual([
121
- "C++ notes",
122
- ]);
123
- expect(unicodeResult.evidence.map((item) => item.title)).toEqual([
124
- "Unicode notes",
125
- ]);
122
+ expect(shortResult.evidence).toEqual([]);
123
+ expect(unicodeResult.evidence).toEqual([]);
126
124
  });
127
125
 
128
126
  test("does not return derived subagent, auto-analysis, or notification conversations", async () => {
@@ -203,27 +201,6 @@ describe("searchConversationSource", () => {
203
201
  ]);
204
202
  });
205
203
 
206
- test("excludes legacy private conversations through the LIKE fallback", async () => {
207
- const visible = await seedConversation({
208
- title: "Visible non-ASCII conversation",
209
- content: "東京 appears in a normal conversation.",
210
- });
211
- const legacyPrivate = await seedConversation({
212
- title: "Legacy private non-ASCII conversation",
213
- content: "東京 appears in a private conversation.",
214
- });
215
- rawRun(
216
- "UPDATE conversations SET conversation_type = 'private' WHERE id = ?",
217
- legacyPrivate.conversation.id,
218
- );
219
-
220
- const result = await searchConversationSource("東京", makeContext(), 10);
221
-
222
- expect(result.evidence.map((item) => item.locator)).toEqual([
223
- `${visible.conversation.id}#${visible.message.id}`,
224
- ]);
225
- });
226
-
227
204
  test("includes archived, scheduled, and background conversations", async () => {
228
205
  const archived = await seedConversation({
229
206
  title: "Archived conversation",
@@ -407,14 +384,13 @@ describe("searchConversationSource with the qdrant backend", () => {
407
384
  getDb().run("DELETE FROM conversations");
408
385
  lexicalCalls = [];
409
386
  suppressIndexing = false;
410
- // These tests exercise the populated-index (post-backfill) qdrant path, so
411
- // mark the backfill complete. The completion gate is covered by its own test.
387
+ // The qdrant backend is selected once the index is populated: not suppressed
388
+ // + backfill complete. These tests exercise that post-backfill path, so mark
389
+ // the backfill complete. The completion gate is covered by its own test.
412
390
  setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
413
- setOverridesForTesting({ "messages-search-backend": true });
414
391
  });
415
392
 
416
393
  afterEach(() => {
417
- setOverridesForTesting({});
418
394
  suppressIndexing = false;
419
395
  deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
420
396
  lexicalMockImpl = () => {
@@ -424,7 +400,7 @@ describe("searchConversationSource with the qdrant backend", () => {
424
400
  };
425
401
  });
426
402
 
427
- test("falls back to FTS when memory indexing is suppressed, even with the qdrant flag on", async () => {
403
+ test("falls back to FTS when memory indexing is suppressed", async () => {
428
404
  const match = await seedConversation({
429
405
  title: "Suppressed indexing notes",
430
406
  content: "The suppressedtoken decision is recorded here.",
@@ -451,7 +427,7 @@ describe("searchConversationSource with the qdrant backend", () => {
451
427
  ]);
452
428
  });
453
429
 
454
- test("falls back to FTS until the backfill completion checkpoint is set, even with the qdrant flag on", async () => {
430
+ test("falls back to FTS until the backfill completion checkpoint is set", async () => {
455
431
  const match = await seedConversation({
456
432
  title: "Pre-backfill notes",
457
433
  content: "The prebackfilltoken decision is recorded here.",
@@ -480,16 +456,17 @@ describe("searchConversationSource with the qdrant backend", () => {
480
456
  ]);
481
457
  });
482
458
 
483
- test("routes short/punctuation-only queries to the exact LIKE path, not Qdrant", async () => {
484
- const match = await seedConversation({
459
+ test("short/punctuation-only queries return no evidence and never hit Qdrant", async () => {
460
+ await seedConversation({
485
461
  title: "C++ notes",
486
462
  role: "user",
487
463
  content: "Use C++ when the example needs deterministic lifetime notes.",
488
464
  });
489
465
 
490
- // `C++` produces no usable ≥2-char FTS match shape, so the source must use
491
- // the exact LIKE path rather than the sparse encoder (which would still
492
- // emit a noisy 1-char `c` token). The mock throws to prove it never runs.
466
+ // `C++` produces no usable ≥2-char FTS match shape. The sparse encoder
467
+ // would still emit a noisy 1-char `c` token, so the source must return no
468
+ // evidence WITHOUT querying the index. The mock throws to prove it never
469
+ // runs.
493
470
  lexicalMockImpl = async () => {
494
471
  throw new Error("searchMessageIdsLexical must not run for a short query");
495
472
  };
@@ -497,9 +474,7 @@ describe("searchConversationSource with the qdrant backend", () => {
497
474
  const result = await searchConversationSource("C++", makeContext(), 5);
498
475
 
499
476
  expect(lexicalCalls).toHaveLength(0);
500
- expect(result.evidence.map((item) => item.locator)).toEqual([
501
- `${match.conversation.id}#${match.message.id}`,
502
- ]);
477
+ expect(result.evidence).toEqual([]);
503
478
  });
504
479
 
505
480
  test("over-fetches a wide candidate pool from Qdrant, not the FTS prefetch window", async () => {
@@ -622,10 +597,10 @@ describe("searchConversationSource with the qdrant backend", () => {
622
597
  ]);
623
598
  });
624
599
 
625
- test("falls back to LIKE when the Qdrant lookup throws", async () => {
626
- const match = await seedConversation({
627
- title: "Fallback notes",
628
- content: "The likefallbacktoken decision is recorded here.",
600
+ test("returns no evidence when the Qdrant lookup throws", async () => {
601
+ await seedConversation({
602
+ title: "Failure notes",
603
+ content: "The qdranterrortoken decision is recorded here.",
629
604
  });
630
605
 
631
606
  lexicalMockImpl = async () => {
@@ -633,18 +608,18 @@ describe("searchConversationSource with the qdrant backend", () => {
633
608
  };
634
609
 
635
610
  const result = await searchConversationSource(
636
- "likefallbacktoken",
611
+ "qdranterrortoken",
637
612
  makeContext(),
638
613
  5,
639
614
  );
640
615
 
641
- expect(result.evidence.map((item) => item.locator)).toEqual([
642
- `${match.conversation.id}#${match.message.id}`,
643
- ]);
616
+ // Content matching is index-only: the failure is logged and the source
617
+ // yields no conversation evidence — no content-scan recovery.
618
+ expect(result.evidence).toEqual([]);
644
619
  });
645
620
 
646
- test("falls back to LIKE when Qdrant returns no candidates", async () => {
647
- const match = await seedConversation({
621
+ test("returns no evidence when Qdrant returns no candidates", async () => {
622
+ await seedConversation({
648
623
  title: "Empty candidate notes",
649
624
  content: "The emptycandidatetoken decision is recorded here.",
650
625
  });
@@ -657,9 +632,9 @@ describe("searchConversationSource with the qdrant backend", () => {
657
632
  5,
658
633
  );
659
634
 
660
- expect(result.evidence.map((item) => item.locator)).toEqual([
661
- `${match.conversation.id}#${match.message.id}`,
662
- ]);
635
+ // The index is authoritative for content matching — an empty candidate
636
+ // set is an empty result, not a trigger for a table-scan fallback.
637
+ expect(result.evidence).toEqual([]);
663
638
  });
664
639
  });
665
640