@remit/backend 0.0.67 → 0.0.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.67",
3
+ "version": "0.0.69",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,11 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { AccountSettingItem } from "@remit/data-ports";
4
+ import type { RoleMailboxCandidate } from "@remit/data-ports/folder-role";
4
5
  import { CanonicalMailboxRole, MailboxSpecialUse } from "@remit/domain-enums";
5
6
  import {
6
7
  CANONICAL_ROLES,
7
- type FolderCandidate,
8
- findFolderForRole,
9
8
  groupFolderAppointmentsByAccount,
10
9
  loadFolderAppointmentsForAccount,
11
10
  resolveFolderAppointments,
@@ -40,60 +39,18 @@ describe("CANONICAL_ROLES", () => {
40
39
  });
41
40
  });
42
41
 
43
- describe("findFolderForRole", () => {
44
- const folders: FolderCandidate[] = [
45
- { mailboxId: "mb-inbox", fullPath: "INBOX" },
42
+ describe("resolveFolderAppointments", () => {
43
+ const folders: RoleMailboxCandidate[] = [
44
+ { mailboxId: "mb-inbox", fullPath: "INBOX", hierarchyDelimiter: "/" },
46
45
  {
47
- mailboxId: "mb-drafts-empty",
48
- fullPath: "INBOX/Drafts",
49
- specialUse: [MailboxSpecialUse.Drafts],
46
+ mailboxId: "mb-concepten",
47
+ fullPath: "INBOX/Concepten",
48
+ hierarchyDelimiter: "/",
50
49
  },
51
- { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
52
- { mailboxId: "mb-sent", fullPath: "INBOX/Sent" },
53
- { mailboxId: "mb-sent-messages", fullPath: "INBOX/Sent Messages" },
54
- { mailboxId: "mb-news", fullPath: "INBOX/Nieuwsbrieven" },
55
- ];
56
-
57
- it("matches the reserved INBOX name for Inbox", () => {
58
- assert.equal(
59
- findFolderForRole(CanonicalMailboxRole.Inbox, folders),
60
- "mb-inbox",
61
- );
62
- });
63
-
64
- it("prefers the SPECIAL-USE flag over a name hint", () => {
65
- assert.equal(
66
- findFolderForRole(CanonicalMailboxRole.Drafts, folders),
67
- "mb-drafts-empty",
68
- );
69
- });
70
-
71
- it("falls back to a weak name hint when no flag is present", () => {
72
- assert.equal(
73
- findFolderForRole(CanonicalMailboxRole.Sent, folders),
74
- "mb-sent",
75
- );
76
- });
77
-
78
- it("returns null when nothing matches", () => {
79
- assert.equal(findFolderForRole(CanonicalMailboxRole.Junk, folders), null);
80
- });
81
-
82
- it("never matches a plain user folder", () => {
83
- assert.equal(
84
- findFolderForRole(CanonicalMailboxRole.Archive, folders),
85
- null,
86
- );
87
- });
88
- });
89
-
90
- describe("resolveFolderAppointments", () => {
91
- const folders: FolderCandidate[] = [
92
- { mailboxId: "mb-inbox", fullPath: "INBOX" },
93
- { mailboxId: "mb-concepten", fullPath: "INBOX/Concepten" },
94
50
  {
95
51
  mailboxId: "mb-spam",
96
52
  fullPath: "INBOX/Spam",
53
+ hierarchyDelimiter: "/",
97
54
  specialUse: [MailboxSpecialUse.Junk],
98
55
  },
99
56
  ];
@@ -1,67 +1,29 @@
1
- import type {
2
- CanonicalMailboxRole as CanonicalMailboxRoleValue,
3
- FolderAppointment,
4
- } from "@remit/api-openapi-types";
1
+ import type { FolderAppointment } from "@remit/api-openapi-types";
5
2
  import type {
6
3
  AccountSettingItem,
7
4
  IAccountSettingRepository,
8
5
  IMailboxRepository,
9
6
  } from "@remit/data-ports";
10
7
  import {
11
- baseSettingName,
12
- composeSettingName,
13
- SETTING_NAME_SEPARATOR,
14
- } from "@remit/data-ports/account-settings";
15
- import {
16
- AccountSettingName,
17
- CanonicalMailboxRole,
18
- MailboxSpecialUse,
19
- } from "@remit/domain-enums";
8
+ CANONICAL_ROLES,
9
+ type CanonicalMailboxRoleValue,
10
+ composeFolderRoleAppointmentName,
11
+ parseFolderRoleAppointmentName,
12
+ type RoleMailboxCandidate,
13
+ resolveMailboxForRole,
14
+ } from "@remit/data-ports/folder-role";
20
15
 
21
16
  /**
22
17
  * RFC 032 exclusive-folder-appointment (#976): a per-account role→mailbox map.
23
18
  * Each row is a `FolderRoleAppointment#<accountId>#<role>` AccountSetting (RFC
24
19
  * 032 settings tiers), so a role can never be persisted twice for one account —
25
20
  * writing it replaces whichever mailbox previously held it. This module owns
26
- * that persistence plus the read-side proposal (`findFolderForRole`) that fills
27
- * any role the user hasn't appointed yet.
21
+ * that persistence; the read side is `resolveMailboxForRole`
22
+ * (`@remit/data-ports/folder-role`), the same rule every special-folder lookup
23
+ * in the backend and the workers goes through.
28
24
  */
29
25
 
30
- const FOLDER_ROLE_APPOINTMENT = AccountSettingName.FolderRoleAppointment;
31
-
32
- /** The fixed anchor set, in the RFC's canonical display order. */
33
- export const CANONICAL_ROLES: readonly CanonicalMailboxRoleValue[] =
34
- Object.values(CanonicalMailboxRole);
35
-
36
- const composeAppointmentName = (
37
- accountId: string,
38
- role: CanonicalMailboxRoleValue,
39
- ): string =>
40
- composeSettingName(
41
- FOLDER_ROLE_APPOINTMENT,
42
- `${accountId}${SETTING_NAME_SEPARATOR}${role}`,
43
- );
44
-
45
- /**
46
- * Split a stored `FolderRoleAppointment#<accountId>#<role>` name back into its
47
- * two-part target. Unlike the single-target composites (`MailboxRole#<id>`),
48
- * this setting composes two ids after the base, so it parses the suffix itself
49
- * rather than reusing `targetIdOf`.
50
- */
51
- const parseAppointmentTarget = (
52
- name: string,
53
- ): { accountId: string; role: string } | undefined => {
54
- if (baseSettingName(name) !== FOLDER_ROLE_APPOINTMENT) return undefined;
55
- const idx = name.indexOf(SETTING_NAME_SEPARATOR);
56
- if (idx === -1) return undefined;
57
- const rest = name.slice(idx + SETTING_NAME_SEPARATOR.length);
58
- const roleIdx = rest.lastIndexOf(SETTING_NAME_SEPARATOR);
59
- if (roleIdx === -1) return undefined;
60
- const accountId = rest.slice(0, roleIdx);
61
- const role = rest.slice(roleIdx + SETTING_NAME_SEPARATOR.length);
62
- if (!accountId || !role) return undefined;
63
- return { accountId, role };
64
- };
26
+ export { CANONICAL_ROLES };
65
27
 
66
28
  const stringValueOf = (item: AccountSettingItem): string | undefined => {
67
29
  const { value } = item;
@@ -78,7 +40,7 @@ export const groupFolderAppointmentsByAccount = (
78
40
  ): Map<string, Map<string, string>> => {
79
41
  const byAccount = new Map<string, Map<string, string>>();
80
42
  for (const setting of settings) {
81
- const target = parseAppointmentTarget(setting.name);
43
+ const target = parseFolderRoleAppointmentName(setting.name);
82
44
  if (!target) continue;
83
45
  const mailboxId = stringValueOf(setting);
84
46
  if (mailboxId === undefined) continue;
@@ -103,7 +65,7 @@ export const loadFolderAppointmentsForAccount = async (
103
65
  CANONICAL_ROLES.map(async (role) => {
104
66
  const item = await accountSetting.get(
105
67
  accountConfigId,
106
- composeAppointmentName(accountId, role),
68
+ composeFolderRoleAppointmentName(accountId, role),
107
69
  );
108
70
  return [role, item ? stringValueOf(item) : undefined] as const;
109
71
  }),
@@ -128,7 +90,7 @@ export const writeFolderRoleAppointment = (
128
90
  role: CanonicalMailboxRoleValue,
129
91
  mailboxId: string | null,
130
92
  ): Promise<unknown> => {
131
- const name = composeAppointmentName(accountId, role);
93
+ const name = composeFolderRoleAppointmentName(accountId, role);
132
94
  if (mailboxId === null) {
133
95
  return accountSetting.delete(accountConfigId, name);
134
96
  }
@@ -139,110 +101,21 @@ export const writeFolderRoleAppointment = (
139
101
  });
140
102
  };
141
103
 
142
- /** The minimal folder shape `findFolderForRole` needs to detect a role. */
143
- export interface FolderCandidate {
144
- mailboxId: string;
145
- fullPath: string;
146
- specialUse?: readonly string[];
147
- }
148
-
149
- // RFC 6154 SPECIAL-USE flag per role. Inbox has no SPECIAL-USE flag (RFC 3501
150
- // reserves the name itself); a role with no entry here is matched by name hint
151
- // only.
152
- const ROLE_TO_SPECIAL_USE: Partial<Record<CanonicalMailboxRoleValue, string>> =
153
- {
154
- [CanonicalMailboxRole.Drafts]: MailboxSpecialUse.Drafts,
155
- [CanonicalMailboxRole.Sent]: MailboxSpecialUse.Sent,
156
- [CanonicalMailboxRole.Archive]: MailboxSpecialUse.Archive,
157
- [CanonicalMailboxRole.Junk]: MailboxSpecialUse.Junk,
158
- [CanonicalMailboxRole.Trash]: MailboxSpecialUse.Trash,
159
- [CanonicalMailboxRole.All]: MailboxSpecialUse.All,
160
- [CanonicalMailboxRole.Flagged]: MailboxSpecialUse.Flagged,
161
- };
162
-
163
- // Weak name hints (RFC 032's tier 3 of `findFolderForRole`): used solely to
164
- // seed a PROPOSAL a human confirms, never to persist a role by itself. Kept
165
- // intentionally small — this is a fallback for providers with no SPECIAL-USE
166
- // support, not a substitute for server truth.
167
- const ROLE_NAME_HINTS: Partial<
168
- Record<CanonicalMailboxRoleValue, readonly string[]>
169
- > = {
170
- [CanonicalMailboxRole.Drafts]: ["drafts", "draft", "concepten"],
171
- [CanonicalMailboxRole.Sent]: [
172
- "sent",
173
- "sent mail",
174
- "sent items",
175
- "sent messages",
176
- ],
177
- [CanonicalMailboxRole.Archive]: ["archive", "archives"],
178
- [CanonicalMailboxRole.Junk]: ["junk", "spam"],
179
- [CanonicalMailboxRole.Trash]: [
180
- "trash",
181
- "bin",
182
- "deleted",
183
- "deleted items",
184
- "deleted messages",
185
- ],
186
- [CanonicalMailboxRole.All]: ["all mail", "all"],
187
- };
188
-
189
- const leafName = (fullPath: string): string => {
190
- const parts = fullPath.split("/");
191
- return (parts[parts.length - 1] || fullPath).toLowerCase();
192
- };
193
-
194
- /**
195
- * The single best EXISTING folder for a canonical role (RFC 032
196
- * exclusive-folder-appointment): the IMAP SPECIAL-USE flag first (server
197
- * truth, language-independent), then — for Inbox only — the reserved `INBOX`
198
- * name (RFC 3501), then a weak name hint used solely to seed a proposal a
199
- * human confirms. `null` when nothing matches; the role stays unfilled.
200
- */
201
- export const findFolderForRole = (
202
- role: CanonicalMailboxRoleValue,
203
- folders: readonly FolderCandidate[],
204
- ): string | null => {
205
- const specialUse = ROLE_TO_SPECIAL_USE[role];
206
- if (specialUse) {
207
- const flagged = folders.find((f) => f.specialUse?.includes(specialUse));
208
- if (flagged) return flagged.mailboxId;
209
- }
210
-
211
- if (role === CanonicalMailboxRole.Inbox) {
212
- const inbox = folders.find((f) => f.fullPath.toUpperCase() === "INBOX");
213
- if (inbox) return inbox.mailboxId;
214
- }
215
-
216
- const hints = ROLE_NAME_HINTS[role];
217
- if (hints) {
218
- const match = folders.find((f) => hints.includes(leafName(f.fullPath)));
219
- if (match) return match.mailboxId;
220
- }
221
-
222
- return null;
223
- };
224
-
225
104
  /**
226
105
  * Resolve the full appointment set for one account: the user's persisted
227
106
  * choice when set (and still a real mailbox — a deleted mailbox's stale
228
- * appointment is treated as unfilled and re-proposed), else a server-proposed
229
- * `findFolderForRole` guess. Always returns one entry per `CANONICAL_ROLES`
230
- * member (RFC 032 settings tiers: total, never a sparse array), so the map is
231
- * never empty for a normal provider.
107
+ * appointment is treated as unfilled and re-proposed), else the server's
108
+ * SPECIAL-USE flag, else a name proposal. Always returns one entry per
109
+ * `CANONICAL_ROLES` member (RFC 032 settings tiers: total, never a sparse
110
+ * array), so the map is never empty for a normal provider.
232
111
  */
233
112
  export const resolveFolderAppointments = (
234
113
  persisted: ReadonlyMap<string, string>,
235
- mailboxes: readonly FolderCandidate[],
114
+ mailboxes: readonly RoleMailboxCandidate[],
236
115
  ): FolderAppointment[] =>
237
116
  CANONICAL_ROLES.map((role) => {
238
- const persistedId = persisted.get(role);
239
- const validPersisted =
240
- persistedId && mailboxes.some((m) => m.mailboxId === persistedId)
241
- ? persistedId
242
- : undefined;
243
- const mailboxId =
244
- validPersisted ?? findFolderForRole(role, mailboxes) ?? undefined;
245
- return mailboxId ? { role, mailboxId } : { role };
117
+ const found = resolveMailboxForRole(role, mailboxes, persisted.get(role));
118
+ return found ? { role, mailboxId: found.mailboxId } : { role };
246
119
  });
247
120
 
248
121
  /**
@@ -5,6 +5,7 @@ import type {
5
5
  import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
6
  import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
7
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
8
+ import { NoTrashMailboxError } from "@remit/mailbox-service";
8
9
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
10
  import { getAccountConfigIdFromEvent } from "../auth.js";
10
11
  import {
@@ -411,14 +412,18 @@ export const TrashOperations: Record<
411
412
  const account = await client.account.get(accountId);
412
413
  assertAccountOwnership(account, accountConfigId, "act");
413
414
 
415
+ // The same confirmed resolution `emptyTrash` expunges through, so the
416
+ // count reported is the count of the folder that gets emptied. It
417
+ // refuses rather than returning zero when no folder is appointed and
418
+ // none is flagged: "0 deleted" reads as success, and the user goes on
419
+ // believing their Trash is empty.
414
420
  const trashMailbox =
415
- await client.mailboxSpecialUse.findTrashMailbox(accountId);
421
+ await client.mailboxSpecialUse.findConfirmedTrashMailbox(accountId);
416
422
 
417
423
  if (!trashMailbox) {
418
- return { deletedCount: 0 };
424
+ throw new NoTrashMailboxError();
419
425
  }
420
426
 
421
- // Get count of messages in trash before emptying
422
427
  const messages = await client.message.listAllByMailbox(
423
428
  trashMailbox.mailboxId,
424
429
  );
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
- import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
3
+ import type {
4
+ CreateFilterAnchorInput,
5
+ FilterAnchorItem,
6
+ FilterItem,
7
+ } from "@remit/data-ports";
4
8
  import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
5
9
  import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
6
10
  import type {
@@ -17,7 +21,10 @@ import {
17
21
  type OrganizeMatchDeps,
18
22
  type OrganizePredicate,
19
23
  } from "./organize.js";
20
- import { _resetSemanticCapabilityForTest } from "./semantic-capability.js";
24
+ import {
25
+ _resetSemanticCapabilityForTest,
26
+ isSemanticSearchUnavailable,
27
+ } from "./semantic-capability.js";
21
28
 
22
29
  const moduleNotFound = (): Error => {
23
30
  const error = new Error("Cannot find package 'sqlite-vec' imported from …");
@@ -28,11 +35,16 @@ const moduleNotFound = (): Error => {
28
35
  const ACCOUNT_CONFIG_ID = "cfg-1";
29
36
  const ANCHOR_VECTOR = [1, 0, 0, 0];
30
37
  const ORTHOGONAL_VECTOR = [0, 1, 0, 0];
38
+ /** The model the fixtures' embedder is currently configured with. */
39
+ const CURRENT_EMBEDDING_ID = "test-model@4";
40
+ /** The model a persisted anchor was written under before a same-dimension swap. */
41
+ const STALE_EMBEDDING_ID = "older-model@4";
42
+ const ANCHOR_SOURCE_TEXT = "book me a table";
31
43
 
32
44
  const anchorPayload: AnchorPayload = {
33
45
  anchorEmbedding: ANCHOR_VECTOR,
34
- anchorEmbeddingId: "test-model@4",
35
- anchorSourceText: "book me a table",
46
+ anchorEmbeddingId: CURRENT_EMBEDDING_ID,
47
+ anchorSourceText: ANCHOR_SOURCE_TEXT,
36
48
  };
37
49
 
38
50
  const metadata = (over: Partial<ChunkMetadata>): ChunkMetadata => ({
@@ -113,6 +125,7 @@ const trackingClient = (
113
125
  const labeled: Array<{ messageId: string; labelId: string }> = [];
114
126
  let filterWrites = 0;
115
127
  let filterAnchorWrites = 0;
128
+ const anchorPuts: CreateFilterAnchorInput[] = [];
116
129
  const activeFilters = seed.activeFilters ?? [];
117
130
  const filterAnchorRows = seed.filterAnchorRows ?? [];
118
131
  const threadMessages = seed.threadMessages ?? {};
@@ -162,9 +175,16 @@ const trackingClient = (
162
175
  refreshExpiry: async (filter: FilterItem) => filter,
163
176
  },
164
177
  filterAnchor: {
165
- put: async () => {
178
+ put: async (input: CreateFilterAnchorInput) => {
166
179
  filterAnchorWrites += 1;
167
- return {} as never;
180
+ anchorPuts.push(input);
181
+ const row: FilterAnchorItem = { ...input, createdAt: 0, updatedAt: 1 };
182
+ const at = filterAnchorRows.findIndex(
183
+ (existing) => existing.filterId === input.filterId,
184
+ );
185
+ if (at === -1) filterAnchorRows.push(row);
186
+ else filterAnchorRows[at] = row;
187
+ return row;
168
188
  },
169
189
  get: async (_accountConfigId: string, filterId: string) =>
170
190
  filterAnchorRows.find((row) => row.filterId === filterId) ?? null,
@@ -176,6 +196,7 @@ const trackingClient = (
176
196
  labeled,
177
197
  filterWrites: () => filterWrites,
178
198
  filterAnchorWrites: () => filterAnchorWrites,
199
+ anchorPuts,
179
200
  };
180
201
  };
181
202
 
@@ -238,10 +259,16 @@ const matchDeps = (
238
259
  vectorStore: store,
239
260
  embed: async (text: string) =>
240
261
  text.includes("reservation") ? ANCHOR_VECTOR : ORTHOGONAL_VECTOR,
262
+ embeddingId: CURRENT_EMBEDDING_ID,
241
263
  };
242
264
  },
243
265
  listAccountFilterMessages: async () => corpus,
244
- filterAnchors: { listByAccountConfig: async () => filterAnchorRows },
266
+ filterAnchors: {
267
+ listByAccountConfig: async () => filterAnchorRows,
268
+ put: async () => {
269
+ throw new Error("matchDeps must not repair an anchor");
270
+ },
271
+ },
245
272
  semanticBuilds: () => semanticBuilds,
246
273
  };
247
274
  };
@@ -273,10 +300,16 @@ const vectorlessDeps = (
273
300
  embed: async () => {
274
301
  throw moduleNotFound();
275
302
  },
303
+ embeddingId: CURRENT_EMBEDDING_ID,
276
304
  };
277
305
  },
278
306
  listAccountFilterMessages: async () => corpus,
279
- filterAnchors: { listByAccountConfig: async () => [] },
307
+ filterAnchors: {
308
+ listByAccountConfig: async () => [],
309
+ put: async () => {
310
+ throw moduleNotFound();
311
+ },
312
+ },
280
313
  semanticUsed: () => semanticUsed,
281
314
  };
282
315
  };
@@ -391,8 +424,8 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
391
424
  accountConfigId: ACCOUNT_CONFIG_ID,
392
425
  filterId: "filter-a",
393
426
  anchorEmbedding: ANCHOR_VECTOR,
394
- anchorEmbeddingId: "test-model@4",
395
- anchorSourceText: "book me a table",
427
+ anchorEmbeddingId: CURRENT_EMBEDDING_ID,
428
+ anchorSourceText: ANCHOR_SOURCE_TEXT,
396
429
  anchorMessageId: "msg-anchor",
397
430
  createdAt: 0,
398
431
  updatedAt: 0,
@@ -409,9 +442,15 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
409
442
  },
410
443
  vectorStore: store,
411
444
  embed: async () => ANCHOR_VECTOR,
445
+ embeddingId: CURRENT_EMBEDDING_ID,
412
446
  }),
413
447
  listAccountFilterMessages: async () => [],
414
- filterAnchors: { listByAccountConfig: async () => [persistedAnchor] },
448
+ filterAnchors: {
449
+ listByAccountConfig: async () => [persistedAnchor],
450
+ put: async () => {
451
+ throw new Error("a current anchor must not be rewritten");
452
+ },
453
+ },
415
454
  };
416
455
 
417
456
  const { messageIds } = await matchOrganize(
@@ -542,6 +581,67 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
542
581
  assert.equal(semanticUnavailable, true);
543
582
  });
544
583
 
584
+ it("keeps widening from a drifted persisted anchor when no embedding model is available", async () => {
585
+ // Self-host: sqlite-vec is present, `@huggingface/transformers` is not.
586
+ // The anchor's stamp will never match — after a model swap, or forever
587
+ // for one pooled from pre-#349 chunks and stamped
588
+ // UNKNOWN_CHUNK_EMBEDDING_ID — and there is no embedder to repair it
589
+ // with, so the widen must query with the vector it has rather than go
590
+ // dark and take every later /search/semantic with it.
591
+ const store = createMemoryVectorStore();
592
+ await store.upsert([
593
+ bodyChunk("msg-1", ANCHOR_VECTOR),
594
+ bodyChunk("msg-stored-space", ORTHOGONAL_VECTOR),
595
+ ]);
596
+ const drifted: FilterAnchorItem = {
597
+ accountConfigId: ACCOUNT_CONFIG_ID,
598
+ filterId: "filter-a",
599
+ anchorEmbedding: ORTHOGONAL_VECTOR,
600
+ anchorEmbeddingId: STALE_EMBEDDING_ID,
601
+ anchorSourceText: ANCHOR_SOURCE_TEXT,
602
+ anchorMessageId: "msg-anchor",
603
+ createdAt: 0,
604
+ updatedAt: 0,
605
+ };
606
+ const deps: OrganizeMatchDeps = {
607
+ semantic: () => ({
608
+ buildAnchor: async () => {
609
+ throw new Error("a persisted anchor must not be re-derived");
610
+ },
611
+ vectorStore: store,
612
+ embed: async () => {
613
+ throw moduleNotFound();
614
+ },
615
+ embeddingId: CURRENT_EMBEDDING_ID,
616
+ }),
617
+ listAccountFilterMessages: async () => [],
618
+ filterAnchors: {
619
+ listByAccountConfig: async () => [drifted],
620
+ put: async () => {
621
+ throw new Error("an unrepairable anchor must not be rewritten");
622
+ },
623
+ },
624
+ };
625
+
626
+ const { messageIds, semanticUnavailable } = await matchOrganize(
627
+ deps,
628
+ ACCOUNT_CONFIG_ID,
629
+ predicate(),
630
+ );
631
+
632
+ assert.deepEqual(
633
+ messageIds,
634
+ ["msg-stored-space"],
635
+ "the kNN read needs no embedder, so the stored anchor vector still returns real hits",
636
+ );
637
+ assert.equal(semanticUnavailable, false);
638
+ assert.equal(
639
+ isSemanticSearchUnavailable(),
640
+ false,
641
+ "one unrepairable anchor must not disable free-text semantic search for the rest of the process",
642
+ );
643
+ });
644
+
545
645
  it("propagates a genuine (non-capability) semantic failure loudly", async () => {
546
646
  const deps: OrganizeMatchDeps = {
547
647
  semantic: () => ({
@@ -553,9 +653,15 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
553
653
  getByMessage: async () => [],
554
654
  },
555
655
  embed: async () => [],
656
+ embeddingId: CURRENT_EMBEDDING_ID,
556
657
  }),
557
658
  listAccountFilterMessages: async () => [],
558
- filterAnchors: { listByAccountConfig: async () => [] },
659
+ filterAnchors: {
660
+ listByAccountConfig: async () => [],
661
+ put: async () => {
662
+ throw new Error("unreachable");
663
+ },
664
+ },
559
665
  };
560
666
 
561
667
  await assert.rejects(
@@ -885,8 +991,8 @@ describe("applyOrganize resolves move precedence against current Active filters
885
991
  accountConfigId: ACCOUNT_CONFIG_ID,
886
992
  filterId: "filter-newer-semantic",
887
993
  anchorEmbedding: ANCHOR_VECTOR,
888
- anchorEmbeddingId: "test-model@4",
889
- anchorSourceText: "book me a table",
994
+ anchorEmbeddingId: CURRENT_EMBEDDING_ID,
995
+ anchorSourceText: ANCHOR_SOURCE_TEXT,
890
996
  anchorMessageId: "msg-anchor-2",
891
997
  createdAt: 0,
892
998
  updatedAt: 0,
@@ -923,6 +1029,186 @@ describe("applyOrganize resolves move precedence against current Active filters
923
1029
  });
924
1030
  });
925
1031
 
1032
+ /**
1033
+ * A same-dimension embedding-model swap leaves every persisted FilterAnchor
1034
+ * stamped with the old model's id and its vector in the old model's space.
1035
+ * Index-time matching repairs that lazily (RFC 039 Decision 1a); the
1036
+ * back-apply paths must do the same, or they score a current-model vector
1037
+ * against a stale-model anchor and silently disagree with the pipeline they
1038
+ * claim to mirror (reader #399).
1039
+ */
1040
+ describe("back-apply repairs a drifted anchor the way index-time matching does (reader #399)", () => {
1041
+ const staleAnchor = (
1042
+ filterId: string,
1043
+ // Written under the previous model: same dimensions, different space, so
1044
+ // nothing but the id stamp distinguishes it from a usable anchor.
1045
+ anchorEmbedding: number[] = ORTHOGONAL_VECTOR,
1046
+ ): FilterAnchorItem => ({
1047
+ accountConfigId: ACCOUNT_CONFIG_ID,
1048
+ filterId,
1049
+ anchorEmbedding,
1050
+ anchorEmbeddingId: STALE_EMBEDDING_ID,
1051
+ anchorSourceText: ANCHOR_SOURCE_TEXT,
1052
+ anchorMessageId: "msg-anchor",
1053
+ createdAt: 0,
1054
+ updatedAt: 0,
1055
+ });
1056
+
1057
+ /** Every text embeds into the current model's space. */
1058
+ const currentModelDeps = (
1059
+ store: ReturnType<typeof createMemoryVectorStore>,
1060
+ anchors: FilterAnchorItem[],
1061
+ embed: (text: string) => Promise<number[]> = async () => ANCHOR_VECTOR,
1062
+ ): OrganizeMatchDeps & { anchorPuts: CreateFilterAnchorInput[] } => {
1063
+ const anchorPuts: CreateFilterAnchorInput[] = [];
1064
+ return {
1065
+ semantic: () => ({
1066
+ buildAnchor: async () => {
1067
+ throw new Error("a persisted anchor must be repaired, not replaced");
1068
+ },
1069
+ vectorStore: store,
1070
+ embed,
1071
+ embeddingId: CURRENT_EMBEDDING_ID,
1072
+ }),
1073
+ listAccountFilterMessages: async () => [],
1074
+ filterAnchors: {
1075
+ listByAccountConfig: async () => anchors,
1076
+ put: async (input: CreateFilterAnchorInput) => {
1077
+ anchorPuts.push(input);
1078
+ return { ...input, createdAt: 0, updatedAt: 1 };
1079
+ },
1080
+ },
1081
+ anchorPuts,
1082
+ };
1083
+ };
1084
+
1085
+ it("matchSemantic re-embeds a drifted persisted anchor instead of querying with the stale vector", async () => {
1086
+ const store = createMemoryVectorStore();
1087
+ await store.upsert([
1088
+ bodyChunk("msg-1", ANCHOR_VECTOR),
1089
+ bodyChunk("msg-stale-space", ORTHOGONAL_VECTOR),
1090
+ ]);
1091
+ const deps = currentModelDeps(store, [staleAnchor("filter-a")]);
1092
+
1093
+ const { messageIds } = await matchOrganize(
1094
+ deps,
1095
+ ACCOUNT_CONFIG_ID,
1096
+ predicate(),
1097
+ );
1098
+
1099
+ assert.deepEqual(
1100
+ messageIds,
1101
+ ["msg-1"],
1102
+ "the widen runs on the re-embedded anchor, not the stale-space vector that would have matched msg-stale-space",
1103
+ );
1104
+ assert.deepEqual(
1105
+ deps.anchorPuts.map((put) => [put.filterId, put.anchorEmbeddingId]),
1106
+ [["filter-a", CURRENT_EMBEDDING_ID]],
1107
+ "the repair is written back in place under the current model's id",
1108
+ );
1109
+ assert.deepEqual(deps.anchorPuts[0].anchorEmbedding, ANCHOR_VECTOR);
1110
+ assert.equal(deps.anchorPuts[0].anchorSourceText, ANCHOR_SOURCE_TEXT);
1111
+ });
1112
+
1113
+ it("filterCurrentlyMatches re-embeds a drifted anchor, so the drifted filter still outranks the move", async () => {
1114
+ const store = createMemoryVectorStore();
1115
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
1116
+ const p = predicate({
1117
+ actionLabelId: "lbl-1",
1118
+ actionMailboxId: "mbox-old",
1119
+ });
1120
+ const newerSemanticFilter = filterItem({
1121
+ filterId: "filter-newer-semantic",
1122
+ ruleChangedAt: 1_000,
1123
+ actionChangedAt: 1_000,
1124
+ actionMailboxId: "mbox-new",
1125
+ hasAnchor: true,
1126
+ });
1127
+ const drifted = staleAnchor("filter-newer-semantic");
1128
+
1129
+ const { messageIds: matched } = await matchOrganize(
1130
+ matchDeps(store),
1131
+ ACCOUNT_CONFIG_ID,
1132
+ p,
1133
+ );
1134
+ const tracked = trackingClient({
1135
+ activeFilters: [newerSemanticFilter],
1136
+ filterAnchorRows: [drifted],
1137
+ threadMessages: { "msg-1": { subject: "Dinner reservation" } },
1138
+ });
1139
+ const mover = trackingMoveService();
1140
+ const result = await applyOrganize(
1141
+ {
1142
+ client: tracked.client,
1143
+ moveService: mover.moveService,
1144
+ match: currentModelDeps(store, [drifted]),
1145
+ },
1146
+ ACCOUNT_CONFIG_ID,
1147
+ matched,
1148
+ p,
1149
+ );
1150
+
1151
+ assert.equal(result.applied, 1);
1152
+ assert.deepEqual(
1153
+ mover.moves,
1154
+ [],
1155
+ "scored against the re-embedded anchor the newer filter contests the move; against the stale one it would silently lose",
1156
+ );
1157
+ assert.deepEqual(
1158
+ tracked.anchorPuts.map((put) => [put.filterId, put.anchorEmbeddingId]),
1159
+ [["filter-newer-semantic", CURRENT_EMBEDDING_ID]],
1160
+ "the repair is written back in place, not left for the next pass",
1161
+ );
1162
+ });
1163
+
1164
+ it("keeps a failed re-embed isolated to its own filter rather than throwing out of the arbitration", async () => {
1165
+ const store = createMemoryVectorStore();
1166
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
1167
+ const p = predicate({ actionMailboxId: "mbox-target" });
1168
+ // The stale vector would score a match, so scoring against it — which is
1169
+ // exactly what this path did before the repair existed — suppresses the
1170
+ // move. A stamp that cannot be honoured is not a match: the filter is
1171
+ // skipped and loudly logged, and the rest of the pass is unaffected.
1172
+ const drifted = staleAnchor("filter-broken-anchor", ANCHOR_VECTOR);
1173
+ const tracked = trackingClient({
1174
+ activeFilters: [
1175
+ filterItem({
1176
+ filterId: "filter-broken-anchor",
1177
+ ruleChangedAt: 1_000,
1178
+ actionChangedAt: 1_000,
1179
+ actionMailboxId: "mbox-elsewhere",
1180
+ hasAnchor: true,
1181
+ }),
1182
+ ],
1183
+ filterAnchorRows: [drifted],
1184
+ threadMessages: { "msg-1": { subject: "Dinner reservation" } },
1185
+ });
1186
+ const mover = trackingMoveService();
1187
+
1188
+ const result = await applyOrganize(
1189
+ {
1190
+ client: tracked.client,
1191
+ moveService: mover.moveService,
1192
+ match: currentModelDeps(store, [drifted], async (text) => {
1193
+ if (text === ANCHOR_SOURCE_TEXT) throw new Error("embedder refused");
1194
+ return ANCHOR_VECTOR;
1195
+ }),
1196
+ },
1197
+ ACCOUNT_CONFIG_ID,
1198
+ ["msg-1"],
1199
+ p,
1200
+ );
1201
+
1202
+ assert.equal(result.applied, 1);
1203
+ assert.equal(result.failed, 0);
1204
+ assert.deepEqual(
1205
+ mover.moves.map((move) => move.destinationMailboxId),
1206
+ ["mbox-target"],
1207
+ "an un-repairable anchor skips its own filter — it neither decides the arbitration on a stale score nor aborts the pass",
1208
+ );
1209
+ });
1210
+ });
1211
+
926
1212
  describe("matchOrganize with ListId and FromDomain clauses", () => {
927
1213
  const senderChunk = (
928
1214
  messageId: string,
@@ -1,11 +1,15 @@
1
+ import { inspect } from "node:util";
1
2
  import type {
3
+ FilterAnchorItem,
2
4
  FilterItem,
3
5
  IFilterAnchorRepository,
4
6
  OrganizeJobRequestItem,
5
7
  } from "@remit/data-ports";
6
8
  import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
7
9
  import { FilterClauseField, FilterState } from "@remit/domain-enums";
10
+ import { logger } from "@remit/logger-lambda";
8
11
  import {
12
+ type AnchorEmbedder,
9
13
  buildMatchText,
10
14
  cosineSimilarity,
11
15
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
@@ -13,6 +17,7 @@ import {
13
17
  literalClausesMatch,
14
18
  NO_ACTION,
15
19
  PlacementMoveService,
20
+ refreshAnchorForEmbedder,
16
21
  selectMoveWinner,
17
22
  } from "@remit/mailbox-service";
18
23
  import {
@@ -26,7 +31,10 @@ import {
26
31
  buildVectorStoreFromEnv,
27
32
  } from "@remit/search-service/from-env";
28
33
  import type { RemitClient } from "./data-client.js";
29
- import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
34
+ import {
35
+ isSemanticCapabilityAbsence,
36
+ noteSemanticCapabilityAbsence,
37
+ } from "./semantic-capability.js";
30
38
 
31
39
  /**
32
40
  * Hard cap on both the previewed and the applied set. A back-apply is a
@@ -96,6 +104,13 @@ export interface OrganizeSemanticDeps {
96
104
  * kNN read (see `semantic-capability.ts`).
97
105
  */
98
106
  embed: (text: string) => Promise<number[]>;
107
+ /**
108
+ * The configured model's `<modelId>@<dimensions>` id, compared against a
109
+ * persisted anchor's `anchorEmbeddingId` to catch a same-dimension model
110
+ * swap that would otherwise score a current-model vector against a
111
+ * stale-model anchor (RFC 039 Decision 1a, reader #399).
112
+ */
113
+ readonly embeddingId: string;
99
114
  }
100
115
 
101
116
  /**
@@ -133,8 +148,10 @@ export interface OrganizeMatchDeps {
133
148
  * "the standing filter this anchor came from," if one still exists, to
134
149
  * read its fixed-at-save-time vector instead of re-deriving one from the
135
150
  * anchor message's current chunks (reader #350 / RFC 039 Decision 1).
151
+ * `put` is the write half of the lazy anchor-drift repair
152
+ * ({@link refreshAnchorForEmbedder}), never a new standing rule.
136
153
  */
137
- filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">;
154
+ filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig" | "put">;
138
155
  }
139
156
 
140
157
  /** The matched ids plus whether the semantic widen was skipped as unavailable. */
@@ -189,31 +206,67 @@ const filterMessageFromChunks = (
189
206
  * rows, read-only, and never touches the vector store. Returns `undefined`
190
207
  * when no standing filter was ever anchored on this message (or it has since
191
208
  * been deleted), in which case the caller falls back to deriving the anchor
192
- * live from the message's current chunk vectors, exactly as before.
209
+ * live from the message's current chunk vectors, exactly as before. The whole
210
+ * row is returned, not just its payload, so the caller can run the same
211
+ * anchor-drift repair index-time matching does.
193
212
  */
194
213
  const findPersistedAnchor = async (
195
214
  filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
196
215
  accountConfigId: string,
197
216
  anchorMessageId: string,
198
- ): Promise<AnchorPayload | undefined> => {
199
- const anchors = await filterAnchors.listByAccountConfig(accountConfigId);
200
- const persisted = anchors.find(
217
+ ): Promise<FilterAnchorItem | undefined> =>
218
+ (await filterAnchors.listByAccountConfig(accountConfigId)).find(
201
219
  (anchor) => anchor.anchorMessageId === anchorMessageId,
202
220
  );
203
- if (!persisted) return undefined;
204
- return {
205
- anchorEmbedding: persisted.anchorEmbedding,
206
- anchorEmbeddingId: persisted.anchorEmbeddingId,
207
- anchorSourceText: persisted.anchorSourceText,
208
- };
209
- };
221
+
222
+ /**
223
+ * The anchor the widen queries with: the persisted row, re-embedded in place
224
+ * when the embedding model has drifted since it was written. A deployment that
225
+ * ships no embedding model cannot repair it, and the kNN read itself needs
226
+ * none, so that case keeps querying with the stored vector rather than going
227
+ * dark — and is classified without recording the absence, which would
228
+ * otherwise disable every later `/search/semantic` (see
229
+ * `semantic-capability.ts`).
230
+ *
231
+ * The classifier is wider than a missing embedder: `LocalEmbeddingService`
232
+ * raises ERR_EMBEDDING_MODEL_UNAVAILABLE for anything that stops the pipeline
233
+ * loading, a corrupt model file included, and a write failure carrying one of
234
+ * those codes lands here too. Any of them falls back to the stale vector, so
235
+ * the fallback is logged — it is the wrong-score condition this repair exists
236
+ * to close, taken deliberately over returning nothing. Every other failure
237
+ * propagates.
238
+ */
239
+ const anchorForWiden = async (
240
+ deps: OrganizeMatchDeps,
241
+ semantic: OrganizeSemanticDeps,
242
+ persisted: FilterAnchorItem,
243
+ ): Promise<FilterAnchorItem> =>
244
+ refreshAnchorForEmbedder(
245
+ { anchorRepository: deps.filterAnchors, embedder: semantic },
246
+ persisted,
247
+ ).catch((error: unknown) => {
248
+ if (!isSemanticCapabilityAbsence(error)) throw error;
249
+ logger.warn(
250
+ {
251
+ alert: "filter_anchor_repair_unavailable",
252
+ filterId: persisted.filterId,
253
+ accountConfigId: persisted.accountConfigId,
254
+ errorName: (error as { name?: string })?.name,
255
+ error: inspect(error),
256
+ },
257
+ "Cannot re-embed a drifted anchor in this deployment; widening on the stored (previous-model) vector rather than returning nothing",
258
+ );
259
+ return persisted;
260
+ });
210
261
 
211
262
  /**
212
263
  * The semantic (anchor) arm: read the anchor vector — the persisted
213
264
  * `FilterAnchor` for a message a standing filter was built from (fixed at
214
265
  * save time, unaffected by the anchor message's later deletion or
215
- * re-chunking), or else pool it fresh from the anchor message's existing
216
- * chunk vectors, the same as before this filter existed or ever had one.
266
+ * re-chunking, and re-embedded in place when the embedding model has drifted
267
+ * since {@link refreshAnchorForEmbedder}), or else pool it fresh from the
268
+ * anchor message's existing chunk vectors, the same as before this filter
269
+ * existed or ever had one.
217
270
  * Fan out with a k-NN query gated on the cosine threshold, then refine by
218
271
  * literal clauses reconstructed from the same chunk vectors. Every read here
219
272
  * goes through the vector store; a deployment without the vector pipeline
@@ -228,13 +281,14 @@ const matchSemantic = async (
228
281
  limit: number,
229
282
  ): Promise<string[] | null> => {
230
283
  const semantic = deps.semantic();
231
- const anchor =
232
- (await findPersistedAnchor(
233
- deps.filterAnchors,
234
- accountConfigId,
235
- predicate.anchorMessageId,
236
- )) ??
237
- (await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId));
284
+ const persisted = await findPersistedAnchor(
285
+ deps.filterAnchors,
286
+ accountConfigId,
287
+ predicate.anchorMessageId,
288
+ );
289
+ const anchor: AnchorPayload | null = persisted
290
+ ? await anchorForWiden(deps, semantic, persisted)
291
+ : await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
238
292
  if (!anchor) return null;
239
293
  const threshold =
240
294
  predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
@@ -408,6 +462,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
408
462
  buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
409
463
  vectorStore: store,
410
464
  embed: async (text) => (await embedder.embed([text]))[0],
465
+ embeddingId: embedder.embeddingId,
411
466
  };
412
467
  return cachedSemantic;
413
468
  };
@@ -548,14 +603,18 @@ const findFilterMessageForPrecedence = async (
548
603
  * Whether one *other* Active filter with a move action currently matches this
549
604
  * message — mirrors `FilterPipeline.filterMatches` (mailbox-service
550
605
  * filters/pipeline.ts) exactly: literal clauses first, then, for a filter
551
- * with a semantic anchor, its own persisted `FilterAnchor` compared against
606
+ * with a semantic anchor, its own persisted `FilterAnchor` re-embedded in
607
+ * place through the same {@link refreshAnchorForEmbedder} the pipeline calls
608
+ * when the model has drifted since the anchor was written — compared against
552
609
  * the candidate's embedding. A stale/incompatible anchor on the *other*
553
610
  * filter is isolated to that filter (skipped, not thrown) — the same
554
- * resilience `filterMatches` gives index-time matching, so one bad anchor
555
- * elsewhere never breaks this back-apply's move.
611
+ * resilience `filterMatches` gives index-time matching, so one bad anchor,
612
+ * or one anchor that cannot be re-embedded, never breaks this back-apply's
613
+ * move.
556
614
  */
557
615
  const filterCurrentlyMatches = async (
558
- filterAnchorService: Pick<IFilterAnchorRepository, "get">,
616
+ filterAnchorService: Pick<IFilterAnchorRepository, "get" | "put">,
617
+ embedder: () => AnchorEmbedder,
559
618
  accountConfigId: string,
560
619
  filter: FilterItem,
561
620
  msg: FilterMessage,
@@ -567,21 +626,37 @@ const filterCurrentlyMatches = async (
567
626
  if (!filter.hasAnchor) {
568
627
  return filter.literalClauses.length > 0;
569
628
  }
570
- const anchor = await filterAnchorService.get(
629
+ const stored = await filterAnchorService.get(
571
630
  accountConfigId,
572
631
  filter.filterId,
573
632
  );
574
- if (!anchor) return false;
633
+ if (!stored) return false;
575
634
  const vector = await embed();
576
635
  if (!vector) return false;
577
- try {
578
- return (
579
- cosineSimilarity(vector, anchor.anchorEmbedding) >=
580
- DEFAULT_SEMANTIC_MATCH_THRESHOLD
636
+ const repairAnchor = async (): Promise<FilterAnchorItem> =>
637
+ refreshAnchorForEmbedder(
638
+ { anchorRepository: filterAnchorService, embedder: embedder() },
639
+ stored,
581
640
  );
582
- } catch {
583
- return false;
584
- }
641
+ return repairAnchor()
642
+ .then(
643
+ (anchor) =>
644
+ cosineSimilarity(vector, anchor.anchorEmbedding) >=
645
+ DEFAULT_SEMANTIC_MATCH_THRESHOLD,
646
+ )
647
+ .catch((error: unknown) => {
648
+ logger.error(
649
+ {
650
+ alert: "filter_anchor_match_failed",
651
+ filterId: filter.filterId,
652
+ accountConfigId,
653
+ errorName: (error as { name?: string })?.name,
654
+ error: inspect(error),
655
+ },
656
+ "Filter anchor comparison failed during back-apply precedence; skipping this filter, the rest still arbitrate (bad/stale anchor vector or a failed repair, non-fatal)",
657
+ );
658
+ return false;
659
+ });
585
660
  };
586
661
 
587
662
  /**
@@ -634,6 +709,7 @@ const findCurrentMoveWinner = async (
634
709
  for (const filter of movers) {
635
710
  const isMatch = await filterCurrentlyMatches(
636
711
  deps.client.filterAnchor,
712
+ () => deps.match.semantic(),
637
713
  accountConfigId,
638
714
  filter,
639
715
  msg,
@@ -20,7 +20,13 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
20
20
  * model involved — so it needs only `sqlite-vec`, which the backend image now
21
21
  * carries as a musl build (Dockerfile sqlite-vec-musl stage,
22
22
  * SQLITE_VEC_EXTENSION_PATH). matchOrganize never consults the memoized flag
23
- * below; a missing embedder therefore never disables the widen.
23
+ * below; a missing embedder therefore never disables the widen. The one place
24
+ * the widen would reach for an embedder is repairing a persisted FilterAnchor
25
+ * whose model has drifted (organize.ts matchSemantic): with no embedder there
26
+ * is no repair to be had, so it queries with the stored vector and classifies
27
+ * the failure through {@link isSemanticCapabilityAbsence}, which deliberately
28
+ * does not record the absence. Recording it there would take every later
29
+ * free-text semantic query down on the strength of one filter's stale stamp.
24
30
  *
25
31
  * The e2e-dev lane runs this backend from source rather than the container, so
26
32
  * `@huggingface/transformers` IS present and the local embedder instead tries to
@@ -59,17 +65,25 @@ export const _resetSemanticCapabilityForTest = (): void => {
59
65
 
60
66
  export const isSemanticSearchUnavailable = (): boolean => semanticUnavailable;
61
67
 
68
+ /**
69
+ * Whether a failure is this deployment simply not carrying the semantic
70
+ * pipeline — the missing-module/extension shape on a self-host SQL backend.
71
+ * Records nothing, for the caller that must degrade one operation without
72
+ * disabling `/search/semantic` process-wide.
73
+ */
74
+ export const isSemanticCapabilityAbsence = (error: unknown): boolean => {
75
+ if (!isSelfHostSqlBackend()) return false;
76
+ const code = (error as { code?: unknown } | null)?.code;
77
+ return typeof code === "string" && CAPABILITY_ABSENCE_CODES.has(code);
78
+ };
79
+
62
80
  /**
63
81
  * Classify a semantic-search failure. Returns true — and remembers the
64
82
  * absence — when it is the missing-module/extension shape on a self-host SQL
65
83
  * backend; any other error is the caller's to rethrow.
66
84
  */
67
85
  export const noteSemanticCapabilityAbsence = (error: unknown): boolean => {
68
- if (!isSelfHostSqlBackend()) return false;
69
- const code = (error as { code?: unknown } | null)?.code;
70
- if (typeof code !== "string" || !CAPABILITY_ABSENCE_CODES.has(code)) {
71
- return false;
72
- }
86
+ if (!isSemanticCapabilityAbsence(error)) return false;
73
87
  if (!semanticUnavailable) {
74
88
  logger.warn(
75
89
  { error: error instanceof Error ? error.message : String(error) },