@remit/backend 0.0.68 → 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.68",
3
+ "version": "0.0.69",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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) },