@remit/backend 0.0.41 → 0.0.42

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.41",
3
+ "version": "0.0.42",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
- import { FilterMatchOperator } from "@remit/domain-enums";
3
+ import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
4
+ import { NotFoundError } from "@remit/data-ports/errors";
5
+ import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
4
6
  import type {
5
7
  AnchorPayload,
6
8
  ChunkMetadata,
@@ -68,15 +70,51 @@ const predicate = (
68
70
  ...over,
69
71
  });
70
72
 
73
+ /** A standing filter fixture — the "other" filters the precedence check reads. */
74
+ const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
75
+ filterId: "filter-other",
76
+ accountConfigId: ACCOUNT_CONFIG_ID,
77
+ name: "other filter",
78
+ scope: "Standing",
79
+ state: FilterState.Active,
80
+ hasAnchor: false,
81
+ ruleChangedAt: 0,
82
+ matchOperator: FilterMatchOperator.And,
83
+ literalClauses: [],
84
+ actionLabelId: "None",
85
+ actionMailboxId: "None",
86
+ createdAt: 0,
87
+ updatedAt: 0,
88
+ ...over,
89
+ });
90
+
71
91
  /**
72
92
  * A client that records MessageLabel writes and blows up if the back-apply path
73
93
  * ever touches Filter/FilterAnchor — the RFC 034 guardrail: this scope never
74
94
  * persists a standing rule.
95
+ *
96
+ * `activeFilters`/`filterAnchorRows`/`threadMessages` model the account's
97
+ * *other*, already-existing standing filters and message rows the exclusive-
98
+ * move precedence check (reader #350) reads — empty by default, so every
99
+ * existing test (which never seeded a competing filter) is unaffected and the
100
+ * precedence check is a same-length no-op.
75
101
  */
76
- const trackingClient = () => {
102
+ const trackingClient = (
103
+ seed: {
104
+ activeFilters?: FilterItem[];
105
+ filterAnchorRows?: FilterAnchorItem[];
106
+ threadMessages?: Record<
107
+ string,
108
+ { fromEmail?: string; fromName?: string; subject?: string }
109
+ >;
110
+ } = {},
111
+ ) => {
77
112
  const labeled: Array<{ messageId: string; labelId: string }> = [];
78
113
  let filterWrites = 0;
79
114
  let filterAnchorWrites = 0;
115
+ const activeFilters = seed.activeFilters ?? [];
116
+ const filterAnchorRows = seed.filterAnchorRows ?? [];
117
+ const threadMessages = seed.threadMessages ?? {};
80
118
  const client = {
81
119
  messageLabel: {
82
120
  apply: async (input: {
@@ -100,17 +138,36 @@ const trackingClient = () => {
100
138
  mailbox: {
101
139
  resolveAccountId: async () => "acct-1",
102
140
  },
141
+ threadMessage: {
142
+ get: async (_accountConfigId: string, messageId: string) => {
143
+ const row = threadMessages[messageId];
144
+ if (!row) {
145
+ throw new NotFoundError("ThreadMessage not found");
146
+ }
147
+ return {
148
+ threadMessageId: messageId,
149
+ fromEmail: row.fromEmail,
150
+ fromName: row.fromName,
151
+ subject: row.subject,
152
+ } as never;
153
+ },
154
+ },
103
155
  filter: {
104
156
  create: async () => {
105
157
  filterWrites += 1;
106
158
  return {} as never;
107
159
  },
160
+ listByAccountAndState: async () => activeFilters,
161
+ refreshExpiry: async (filter: FilterItem) => filter,
108
162
  },
109
163
  filterAnchor: {
110
164
  put: async () => {
111
165
  filterAnchorWrites += 1;
112
166
  return {} as never;
113
167
  },
168
+ get: async (_accountConfigId: string, filterId: string) =>
169
+ filterAnchorRows.find((row) => row.filterId === filterId) ?? null,
170
+ listByAccountConfig: async () => filterAnchorRows,
114
171
  },
115
172
  } as unknown as RemitClient;
116
173
  return {
@@ -160,19 +217,30 @@ const trackingMoveService = () => {
160
217
  * Deps whose semantic side is the in-memory vector store — the vector-backed
161
218
  * deployment. `listAccountFilterMessages` returns the given corpus (empty by
162
219
  * default), and constructing the semantic side is tracked so a literal-only
163
- * predicate can be shown never to build it.
220
+ * predicate can be shown never to build it. `filterAnchorRows` seeds the
221
+ * account's *persisted* FilterAnchor rows (empty by default) — the reverse
222
+ * lookup back-apply's anchor consultation reads (reader #350) — and `embed`
223
+ * is a deterministic stand-in for the configured embedding model, used only
224
+ * by the exclusive-move precedence check.
164
225
  */
165
226
  const matchDeps = (
166
227
  store: ReturnType<typeof createMemoryVectorStore>,
167
228
  corpus: OrganizeCandidate[] = [],
229
+ filterAnchorRows: FilterAnchorItem[] = [],
168
230
  ): OrganizeMatchDeps & { semanticBuilds: () => number } => {
169
231
  let semanticBuilds = 0;
170
232
  return {
171
233
  semantic: () => {
172
234
  semanticBuilds += 1;
173
- return { buildAnchor: async () => anchorPayload, vectorStore: store };
235
+ return {
236
+ buildAnchor: async () => anchorPayload,
237
+ vectorStore: store,
238
+ embed: async (text: string) =>
239
+ text.includes("reservation") ? ANCHOR_VECTOR : ORTHOGONAL_VECTOR,
240
+ };
174
241
  },
175
242
  listAccountFilterMessages: async () => corpus,
243
+ filterAnchors: { listByAccountConfig: async () => filterAnchorRows },
176
244
  semanticBuilds: () => semanticBuilds,
177
245
  };
178
246
  };
@@ -201,9 +269,13 @@ const vectorlessDeps = (
201
269
  throw moduleNotFound();
202
270
  },
203
271
  },
272
+ embed: async () => {
273
+ throw moduleNotFound();
274
+ },
204
275
  };
205
276
  },
206
277
  listAccountFilterMessages: async () => corpus,
278
+ filterAnchors: { listByAccountConfig: async () => [] },
207
279
  semanticUsed: () => semanticUsed,
208
280
  };
209
281
  };
@@ -305,6 +377,77 @@ describe("matchOrganize", () => {
305
377
  });
306
378
  });
307
379
 
380
+ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () => {
381
+ it("still matches by reading the persisted anchor after the anchor message is purged", async () => {
382
+ const store = createMemoryVectorStore();
383
+ const matching = ["msg-1", "msg-2"];
384
+ await store.upsert([
385
+ ...matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)),
386
+ bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
387
+ ]);
388
+
389
+ const persistedAnchor: FilterAnchorItem = {
390
+ accountConfigId: ACCOUNT_CONFIG_ID,
391
+ filterId: "filter-a",
392
+ anchorEmbedding: ANCHOR_VECTOR,
393
+ anchorEmbeddingId: "test-model@4",
394
+ anchorSourceText: "book me a table",
395
+ anchorMessageId: "msg-anchor",
396
+ createdAt: 0,
397
+ updatedAt: 0,
398
+ };
399
+
400
+ let buildAnchorCalls = 0;
401
+ const deps: OrganizeMatchDeps = {
402
+ semantic: () => ({
403
+ buildAnchor: async () => {
404
+ // The live path: the anchor message's chunks are gone (purged),
405
+ // exactly what buildMessageAnchor returns in that case today.
406
+ buildAnchorCalls += 1;
407
+ return null;
408
+ },
409
+ vectorStore: store,
410
+ embed: async () => ANCHOR_VECTOR,
411
+ }),
412
+ listAccountFilterMessages: async () => [],
413
+ filterAnchors: { listByAccountConfig: async () => [persistedAnchor] },
414
+ };
415
+
416
+ const { messageIds } = await matchOrganize(
417
+ deps,
418
+ ACCOUNT_CONFIG_ID,
419
+ predicate(),
420
+ );
421
+
422
+ assert.deepEqual(
423
+ [...messageIds].sort(),
424
+ matching,
425
+ "the persisted anchor vector still finds the same matches the live message would have",
426
+ );
427
+ assert.equal(
428
+ buildAnchorCalls,
429
+ 0,
430
+ "a persisted anchor must be read instead of re-deriving one from the (now-gone) live message",
431
+ );
432
+ });
433
+
434
+ it("falls back to deriving the anchor from the live message when no filter was ever anchored on it", async () => {
435
+ const store = createMemoryVectorStore();
436
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
437
+
438
+ // No persisted FilterAnchor names this anchorMessageId — an ad hoc "all
439
+ // like these" widen over a bare message selection, never tied to a
440
+ // standing filter.
441
+ const { messageIds } = await matchOrganize(
442
+ matchDeps(store),
443
+ ACCOUNT_CONFIG_ID,
444
+ predicate(),
445
+ );
446
+
447
+ assert.deepEqual(messageIds, ["msg-1"]);
448
+ });
449
+ });
450
+
308
451
  describe("matchOrganize on a deployment without the vector pipeline", () => {
309
452
  const ORIGINAL = process.env.DATA_BACKEND;
310
453
  beforeEach(() => {
@@ -403,8 +546,10 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
403
546
  query: async () => [],
404
547
  getByMessage: async () => [],
405
548
  },
549
+ embed: async () => [],
406
550
  }),
407
551
  listAccountFilterMessages: async () => [],
552
+ filterAnchors: { listByAccountConfig: async () => [] },
408
553
  };
409
554
 
410
555
  await assert.rejects(
@@ -437,7 +582,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
437
582
 
438
583
  const tracked = trackingClient();
439
584
  const result = await applyOrganize(
440
- { client: tracked.client },
585
+ { client: tracked.client, match: matchDeps(store) },
441
586
  ACCOUNT_CONFIG_ID,
442
587
  applied.messageIds,
443
588
  p,
@@ -473,7 +618,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
473
618
  );
474
619
  const tracked = trackingClient();
475
620
  const result = await applyOrganize(
476
- { client: tracked.client },
621
+ { client: tracked.client, match: matchDeps(store) },
477
622
  ACCOUNT_CONFIG_ID,
478
623
  matched,
479
624
  p,
@@ -500,7 +645,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
500
645
  const tracked = trackingClient();
501
646
  const mover = trackingMoveService();
502
647
  const result = await applyOrganize(
503
- { client: tracked.client, moveService: mover.moveService },
648
+ {
649
+ client: tracked.client,
650
+ moveService: mover.moveService,
651
+ match: matchDeps(store),
652
+ },
504
653
  ACCOUNT_CONFIG_ID,
505
654
  matched,
506
655
  p,
@@ -546,7 +695,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
546
695
  const tracked = trackingClient();
547
696
  const mover = trackingMoveService();
548
697
  const result = await applyOrganize(
549
- { client: tracked.client, moveService: mover.moveService },
698
+ {
699
+ client: tracked.client,
700
+ moveService: mover.moveService,
701
+ match: matchDeps(store),
702
+ },
550
703
  ACCOUNT_CONFIG_ID,
551
704
  matched,
552
705
  p,
@@ -578,13 +731,21 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
578
731
  const mover = trackingMoveService();
579
732
 
580
733
  const first = await applyOrganize(
581
- { client: tracked.client, moveService: mover.moveService },
734
+ {
735
+ client: tracked.client,
736
+ moveService: mover.moveService,
737
+ match: matchDeps(store),
738
+ },
582
739
  ACCOUNT_CONFIG_ID,
583
740
  matched,
584
741
  p,
585
742
  );
586
743
  const second = await applyOrganize(
587
- { client: tracked.client, moveService: mover.moveService },
744
+ {
745
+ client: tracked.client,
746
+ moveService: mover.moveService,
747
+ match: matchDeps(store),
748
+ },
588
749
  ACCOUNT_CONFIG_ID,
589
750
  matched,
590
751
  p,
@@ -605,6 +766,154 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
605
766
  });
606
767
  });
607
768
 
769
+ describe("applyOrganize resolves move precedence against current Active filters (reader #350)", () => {
770
+ it("suppresses an out-ranked move but still applies the label", async () => {
771
+ const store = createMemoryVectorStore();
772
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
773
+ // The back-applied filter — "move to mbox-old" — is out-ranked by a
774
+ // more-recently-changed standing filter that currently claims msg-1 for a
775
+ // different destination.
776
+ const p = predicate({
777
+ actionLabelId: "lbl-1",
778
+ actionMailboxId: "mbox-old",
779
+ });
780
+ const newerFilter = filterItem({
781
+ filterId: "filter-newer",
782
+ ruleChangedAt: 1_000,
783
+ actionMailboxId: "mbox-new",
784
+ literalClauses: [{ field: "Subject", value: "reservation" }],
785
+ });
786
+
787
+ const { messageIds: matched } = await matchOrganize(
788
+ matchDeps(store),
789
+ ACCOUNT_CONFIG_ID,
790
+ p,
791
+ );
792
+ const tracked = trackingClient({
793
+ activeFilters: [newerFilter],
794
+ threadMessages: { "msg-1": { subject: "Dinner reservation" } },
795
+ });
796
+ const mover = trackingMoveService();
797
+ const result = await applyOrganize(
798
+ {
799
+ client: tracked.client,
800
+ moveService: mover.moveService,
801
+ match: matchDeps(store),
802
+ },
803
+ ACCOUNT_CONFIG_ID,
804
+ matched,
805
+ p,
806
+ );
807
+
808
+ assert.equal(result.applied, 1, "a suppressed move is not a failure");
809
+ assert.equal(result.failed, 0);
810
+ assert.deepEqual(
811
+ tracked.labeled,
812
+ [{ messageId: "msg-1", labelId: "lbl-1" }],
813
+ "the additive label still applies even though the move is suppressed",
814
+ );
815
+ assert.deepEqual(
816
+ mover.moves,
817
+ [],
818
+ "the exclusive move is skipped in favor of the newer filter's own move",
819
+ );
820
+ });
821
+
822
+ it("moves the message when no other Active filter currently outranks it", async () => {
823
+ const store = createMemoryVectorStore();
824
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
825
+ const p = predicate({ actionMailboxId: "mbox-target" });
826
+ // A different standing filter matches this message too, but agrees on the
827
+ // same destination — nothing to defer to.
828
+ const agreeingFilter = filterItem({
829
+ filterId: "filter-agrees",
830
+ ruleChangedAt: 1_000,
831
+ actionMailboxId: "mbox-target",
832
+ literalClauses: [{ field: "Subject", value: "reservation" }],
833
+ });
834
+
835
+ const { messageIds: matched } = await matchOrganize(
836
+ matchDeps(store),
837
+ ACCOUNT_CONFIG_ID,
838
+ p,
839
+ );
840
+ const tracked = trackingClient({
841
+ activeFilters: [agreeingFilter],
842
+ threadMessages: { "msg-1": { subject: "Dinner reservation" } },
843
+ });
844
+ const mover = trackingMoveService();
845
+ const result = await applyOrganize(
846
+ {
847
+ client: tracked.client,
848
+ moveService: mover.moveService,
849
+ match: matchDeps(store),
850
+ },
851
+ ACCOUNT_CONFIG_ID,
852
+ matched,
853
+ p,
854
+ );
855
+
856
+ assert.equal(result.applied, 1);
857
+ assert.equal(result.failed, 0);
858
+ assert.deepEqual(
859
+ mover.moves.map((m) => m.messageId),
860
+ ["msg-1"],
861
+ "the move proceeds exactly as it would have before this check existed",
862
+ );
863
+ });
864
+
865
+ it("suppresses an out-ranked move by a newer *semantic* filter's persisted anchor", async () => {
866
+ const store = createMemoryVectorStore();
867
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
868
+ const p = predicate({ actionMailboxId: "mbox-old" });
869
+ const newerSemanticFilter = filterItem({
870
+ filterId: "filter-newer-semantic",
871
+ ruleChangedAt: 1_000,
872
+ actionMailboxId: "mbox-new",
873
+ hasAnchor: true,
874
+ });
875
+ const persistedAnchor: FilterAnchorItem = {
876
+ accountConfigId: ACCOUNT_CONFIG_ID,
877
+ filterId: "filter-newer-semantic",
878
+ anchorEmbedding: ANCHOR_VECTOR,
879
+ anchorEmbeddingId: "test-model@4",
880
+ anchorSourceText: "book me a table",
881
+ anchorMessageId: "msg-anchor-2",
882
+ createdAt: 0,
883
+ updatedAt: 0,
884
+ };
885
+
886
+ const { messageIds: matched } = await matchOrganize(
887
+ matchDeps(store),
888
+ ACCOUNT_CONFIG_ID,
889
+ p,
890
+ );
891
+ const tracked = trackingClient({
892
+ activeFilters: [newerSemanticFilter],
893
+ filterAnchorRows: [persistedAnchor],
894
+ threadMessages: { "msg-1": { subject: "Dinner reservation" } },
895
+ });
896
+ const mover = trackingMoveService();
897
+ const result = await applyOrganize(
898
+ {
899
+ client: tracked.client,
900
+ moveService: mover.moveService,
901
+ match: matchDeps(store, [], [persistedAnchor]),
902
+ },
903
+ ACCOUNT_CONFIG_ID,
904
+ matched,
905
+ p,
906
+ );
907
+
908
+ assert.equal(result.applied, 1);
909
+ assert.deepEqual(
910
+ mover.moves,
911
+ [],
912
+ "a newer semantic filter's own persisted anchor outranks the move",
913
+ );
914
+ });
915
+ });
916
+
608
917
  describe("matchOrganize with ListId and FromDomain clauses", () => {
609
918
  const senderChunk = (
610
919
  messageId: string,
@@ -702,7 +1011,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
702
1011
 
703
1012
  const tracked = trackingClient();
704
1013
  const result = await applyOrganize(
705
- { client: tracked.client },
1014
+ { client: tracked.client, match: deps },
706
1015
  ACCOUNT_CONFIG_ID,
707
1016
  applied.messageIds,
708
1017
  p,
@@ -1,11 +1,19 @@
1
- import type { FilterItem, OrganizeJobRequestItem } from "@remit/data-ports";
2
- import { FilterClauseField } from "@remit/domain-enums";
1
+ import type {
2
+ FilterItem,
3
+ IFilterAnchorRepository,
4
+ OrganizeJobRequestItem,
5
+ } from "@remit/data-ports";
6
+ import { NotFoundError } from "@remit/data-ports/errors";
7
+ import { FilterClauseField, FilterState } from "@remit/domain-enums";
3
8
  import {
9
+ buildMatchText,
10
+ cosineSimilarity,
4
11
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
5
12
  type FilterMessage,
6
13
  literalClausesMatch,
7
14
  NO_ACTION,
8
15
  PlacementMoveService,
16
+ selectMoveWinner,
9
17
  } from "@remit/mailbox-service";
10
18
  import {
11
19
  type AnchorPayload,
@@ -79,6 +87,15 @@ export interface OrganizeSemanticDeps {
79
87
  anchorMessageId: string,
80
88
  ) => Promise<AnchorPayload | null>;
81
89
  vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
90
+ /**
91
+ * Embed one candidate message's text — used only by the cross-filter
92
+ * precedence check ({@link findCurrentMoveWinner}) to compare a message
93
+ * against a *different* Active filter's persisted anchor, the same
94
+ * comparison `FilterPipeline.filterMatches` runs at index time. Never
95
+ * called by the anchor widen itself, which only ever runs a vector-store
96
+ * kNN read (see `semantic-capability.ts`).
97
+ */
98
+ embed: (text: string) => Promise<number[]>;
82
99
  }
83
100
 
84
101
  /**
@@ -107,6 +124,17 @@ export interface OrganizeMatchDeps {
107
124
  accountConfigId: string,
108
125
  limit: number,
109
126
  ) => Promise<OrganizeCandidate[]>;
127
+ /**
128
+ * Every persisted FilterAnchor for the account — read-only, always
129
+ * available (never gated behind {@link semantic}, since it never touches
130
+ * the vector store). A back-apply predicate carries only a bare
131
+ * `anchorMessageId`, never a `filterId` (RFC 034 recap: this job is
132
+ * deliberately not a Filter), so this is how {@link matchSemantic} finds
133
+ * "the standing filter this anchor came from," if one still exists, to
134
+ * read its fixed-at-save-time vector instead of re-deriving one from the
135
+ * anchor message's current chunks (reader #350 / RFC 039 Decision 1).
136
+ */
137
+ filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">;
110
138
  }
111
139
 
112
140
  /** The matched ids plus whether the semantic widen was skipped as unavailable. */
@@ -152,23 +180,61 @@ const filterMessageFromChunks = (
152
180
  };
153
181
 
154
182
  /**
155
- * The semantic (anchor) arm: pool the anchor vector from the anchor message's
156
- * existing chunk vectors, fan out with a k-NN query gated on the cosine
157
- * threshold, then refine by literal clauses reconstructed from the same chunk
158
- * vectors. Every read here goes through the vector store; a deployment without
159
- * the vector pipeline fails on the first call, which {@link matchOrganize}
160
- * catches. Returns null when the anchor message has no vectors to pool.
183
+ * The persisted FilterAnchor whose `anchorMessageId` matches this predicate's
184
+ * anchor, if the account has a standing filter built from that message (RFC
185
+ * 034 Decision 2 / RFC 039 Decision 1, reader #350). A back-apply predicate
186
+ * never carries a `filterId` this job structurally is not a Filter — so the
187
+ * only way to recover "the filter this anchor came from" is by the value it
188
+ * was anchored on. The scan is over one account's (small, bounded) anchor
189
+ * rows, read-only, and never touches the vector store. Returns `undefined`
190
+ * when no standing filter was ever anchored on this message (or it has since
191
+ * 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.
193
+ */
194
+ const findPersistedAnchor = async (
195
+ filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
196
+ accountConfigId: string,
197
+ anchorMessageId: string,
198
+ ): Promise<AnchorPayload | undefined> => {
199
+ const anchors = await filterAnchors.listByAccountConfig(accountConfigId);
200
+ const persisted = anchors.find(
201
+ (anchor) => anchor.anchorMessageId === anchorMessageId,
202
+ );
203
+ if (!persisted) return undefined;
204
+ return {
205
+ anchorEmbedding: persisted.anchorEmbedding,
206
+ anchorEmbeddingId: persisted.anchorEmbeddingId,
207
+ anchorSourceText: persisted.anchorSourceText,
208
+ };
209
+ };
210
+
211
+ /**
212
+ * The semantic (anchor) arm: read the anchor vector — the persisted
213
+ * `FilterAnchor` for a message a standing filter was built from (fixed at
214
+ * 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.
217
+ * Fan out with a k-NN query gated on the cosine threshold, then refine by
218
+ * literal clauses reconstructed from the same chunk vectors. Every read here
219
+ * goes through the vector store; a deployment without the vector pipeline
220
+ * fails on the first call, which {@link matchOrganize} catches. Returns null
221
+ * when neither a persisted anchor nor the message's own chunk vectors exist
222
+ * to pool.
161
223
  */
162
224
  const matchSemantic = async (
163
- semantic: OrganizeSemanticDeps,
225
+ deps: OrganizeMatchDeps,
164
226
  accountConfigId: string,
165
227
  predicate: OrganizePredicate,
166
228
  limit: number,
167
229
  ): Promise<string[] | null> => {
168
- const anchor = await semantic.buildAnchor(
169
- accountConfigId,
170
- predicate.anchorMessageId,
171
- );
230
+ 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));
172
238
  if (!anchor) return null;
173
239
  const threshold =
174
240
  predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
@@ -302,7 +368,7 @@ export const matchOrganize = async (
302
368
 
303
369
  try {
304
370
  const semanticIds = await matchSemantic(
305
- deps.semantic(),
371
+ deps,
306
372
  accountConfigId,
307
373
  predicate,
308
374
  limit,
@@ -343,6 +409,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
343
409
  buildAnchor: (accountConfigId, anchorMessageId) =>
344
410
  buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
345
411
  vectorStore: store,
412
+ embed: async (text) => (await embedder.embed([text]))[0],
346
413
  };
347
414
  return cachedSemantic;
348
415
  };
@@ -404,6 +471,7 @@ export const buildOrganizeMatchDeps = (
404
471
  ): OrganizeMatchDeps => ({
405
472
  semantic: buildSemanticFromEnv,
406
473
  listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
474
+ filterAnchors: client.filterAnchor,
407
475
  });
408
476
 
409
477
  /**
@@ -433,6 +501,13 @@ export const buildOrganizeMoveService = (
433
501
  export interface ApplyOrganizeDeps {
434
502
  client: RemitClient;
435
503
  moveService?: PlacementMoveService;
504
+ /**
505
+ * The same matcher deps {@link matchOrganize} uses — reused here only for
506
+ * their vector-store/embedder access, to compare a candidate message
507
+ * against a *different* filter's persisted anchor when checking exclusive-
508
+ * move precedence (RFC 039 Decision 2, reader #350).
509
+ */
510
+ match: OrganizeMatchDeps;
436
511
  }
437
512
 
438
513
  export interface ApplyOrganizeResult {
@@ -440,6 +515,160 @@ export interface ApplyOrganizeResult {
440
515
  failed: number;
441
516
  }
442
517
 
518
+ /**
519
+ * The vector-free literal-match projection of one already-stored message,
520
+ * read from its ThreadMessage row — the same fields and the same fidelity
521
+ * tradeoff {@link listAccountFilterMessagesFromClient} accepts for the
522
+ * back-apply predicate's own literal clauses (full-fidelity From/Subject/
523
+ * ListId, empty body text, so a `HasWords` clause on a *different* filter
524
+ * cannot be proven to currently match here). `undefined` when the message no
525
+ * longer exists.
526
+ */
527
+ const findFilterMessageForPrecedence = async (
528
+ client: Pick<RemitClient, "threadMessage">,
529
+ accountConfigId: string,
530
+ messageId: string,
531
+ ): Promise<FilterMessage | undefined> => {
532
+ const row = await client.threadMessage
533
+ .get(accountConfigId, messageId)
534
+ .catch((error: unknown) => {
535
+ if (error instanceof NotFoundError) return undefined;
536
+ throw error;
537
+ });
538
+ if (!row) return undefined;
539
+ return {
540
+ from: row.fromEmail ?? "",
541
+ fromName: row.fromName ?? "",
542
+ subject: row.subject ?? "",
543
+ text: "",
544
+ listId: row.listId ?? "",
545
+ };
546
+ };
547
+
548
+ /**
549
+ * Whether one *other* Active filter with a move action currently matches this
550
+ * message — mirrors `FilterPipeline.filterMatches` (mailbox-service
551
+ * filters/pipeline.ts) exactly: literal clauses first, then, for a filter
552
+ * with a semantic anchor, its own persisted `FilterAnchor` compared against
553
+ * the candidate's embedding. A stale/incompatible anchor on the *other*
554
+ * filter is isolated to that filter (skipped, not thrown) — the same
555
+ * resilience `filterMatches` gives index-time matching, so one bad anchor
556
+ * elsewhere never breaks this back-apply's move.
557
+ */
558
+ const filterCurrentlyMatches = async (
559
+ filterAnchorService: Pick<IFilterAnchorRepository, "get">,
560
+ accountConfigId: string,
561
+ filter: FilterItem,
562
+ msg: FilterMessage,
563
+ embed: () => Promise<number[] | null>,
564
+ ): Promise<boolean> => {
565
+ if (!literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)) {
566
+ return false;
567
+ }
568
+ if (!filter.hasAnchor) {
569
+ return filter.literalClauses.length > 0;
570
+ }
571
+ const anchor = await filterAnchorService.get(
572
+ accountConfigId,
573
+ filter.filterId,
574
+ );
575
+ if (!anchor) return false;
576
+ const vector = await embed();
577
+ if (!vector) return false;
578
+ try {
579
+ return (
580
+ cosineSimilarity(vector, anchor.anchorEmbedding) >=
581
+ DEFAULT_SEMANTIC_MATCH_THRESHOLD
582
+ );
583
+ } catch {
584
+ return false;
585
+ }
586
+ };
587
+
588
+ /**
589
+ * The filter that currently wins this message's exclusive move, evaluated
590
+ * fresh against every Active filter with a move action — the same
591
+ * cross-filter arbitration index-time matching runs
592
+ * (`FilterPipeline`/`selectMoveWinner`), never the back-apply job's own
593
+ * snapshotted predicate (RFC 039 Decision 2, reader #350). This is what lets
594
+ * back-apply defer to a filter created or edited *after* the job was
595
+ * requested. `movers` is fetched once per {@link applyOrganize} call, not
596
+ * once per message — a back-apply pass runs in one short burst, so the
597
+ * account's Active filter set does not need re-reading per message.
598
+ *
599
+ * Returns `undefined` — meaning "nothing else contests this move" — whenever
600
+ * there are no other Active mover filters, or the message's own projection
601
+ * cannot be built (deleted between match and apply). Both are the common
602
+ * case and the safe default: proceed with the requested move exactly as
603
+ * before this check existed.
604
+ */
605
+ const findCurrentMoveWinner = async (
606
+ deps: {
607
+ client: Pick<RemitClient, "threadMessage" | "filterAnchor">;
608
+ match: OrganizeMatchDeps;
609
+ },
610
+ movers: readonly FilterItem[],
611
+ accountConfigId: string,
612
+ messageId: string,
613
+ ): Promise<FilterItem | undefined> => {
614
+ if (movers.length === 0) return undefined;
615
+ const msg = await findFilterMessageForPrecedence(
616
+ deps.client,
617
+ accountConfigId,
618
+ messageId,
619
+ );
620
+ if (!msg) return undefined;
621
+
622
+ let messageEmbedding: number[] | null | undefined;
623
+ const embed = async (): Promise<number[] | null> => {
624
+ if (messageEmbedding !== undefined) return messageEmbedding;
625
+ try {
626
+ messageEmbedding = await deps.match.semantic().embed(buildMatchText(msg));
627
+ } catch (error) {
628
+ if (!noteSemanticCapabilityAbsence(error)) throw error;
629
+ messageEmbedding = null;
630
+ }
631
+ return messageEmbedding;
632
+ };
633
+
634
+ const matched: FilterItem[] = [];
635
+ for (const filter of movers) {
636
+ const isMatch = await filterCurrentlyMatches(
637
+ deps.client.filterAnchor,
638
+ accountConfigId,
639
+ filter,
640
+ msg,
641
+ embed,
642
+ );
643
+ if (isMatch) matched.push(filter);
644
+ }
645
+ return selectMoveWinner(matched);
646
+ };
647
+
648
+ /**
649
+ * Every currently-Active filter with a move action, its lazy Temporary-expiry
650
+ * check already applied — the fixed candidate set {@link findCurrentMoveWinner}
651
+ * arbitrates against for every message in this pass. Empty (and read exactly
652
+ * once) when no move action was requested, so a label-only back-apply never
653
+ * pays for this at all.
654
+ */
655
+ const listCurrentMovers = async (
656
+ client: Pick<RemitClient, "filter">,
657
+ accountConfigId: string,
658
+ ): Promise<FilterItem[]> => {
659
+ const active = await client.filter.listByAccountAndState(
660
+ accountConfigId,
661
+ FilterState.Active,
662
+ );
663
+ const movers: FilterItem[] = [];
664
+ for (const filter of active) {
665
+ if (filter.actionMailboxId === NO_ACTION) continue;
666
+ const usable = await client.filter.refreshExpiry(filter);
667
+ if (usable.state === FilterState.Active) movers.push(usable);
668
+ }
669
+ return movers;
670
+ };
671
+
443
672
  /**
444
673
  * Apply the back-apply action to every matched message, reusing the index-time
445
674
  * apply plumbing: an idempotent MessageLabel upsert (additive) with
@@ -447,6 +676,13 @@ export interface ApplyOrganizeResult {
447
676
  * exactly like a hand-applied label (RFC 034 Decision 3.3) — and an idempotent
448
677
  * folder move (exclusive). One poisoned message never fails the batch; it is
449
678
  * counted as failed and the pass continues.
679
+ *
680
+ * Before an exclusive move, resolves whether a *different* Active filter
681
+ * currently wins that message (RFC 039 Decision 2, reader #350): if so, the
682
+ * move is skipped for that message — the label action above, if requested,
683
+ * still applies, since labels are additive and always safe. A message with no
684
+ * contesting filter, or whose own back-applied filter is still the current
685
+ * winner, moves exactly as before this check existed.
450
686
  */
451
687
  export const applyOrganize = async (
452
688
  deps: ApplyOrganizeDeps,
@@ -454,12 +690,16 @@ export const applyOrganize = async (
454
690
  messageIds: readonly string[],
455
691
  predicate: OrganizePredicate,
456
692
  ): Promise<ApplyOrganizeResult> => {
457
- const { client, moveService } = deps;
693
+ const { client, moveService, match } = deps;
458
694
  const applyLabel =
459
695
  predicate.actionLabelId !== NO_ACTION && predicate.actionLabelId !== "";
460
696
  const applyMove =
461
697
  predicate.actionMailboxId !== NO_ACTION && predicate.actionMailboxId !== "";
462
698
 
699
+ const movers = applyMove
700
+ ? await listCurrentMovers(client, accountConfigId)
701
+ : [];
702
+
463
703
  const applyToMessage = async (messageId: string): Promise<void> => {
464
704
  if (applyLabel) {
465
705
  await client.messageLabel.apply({
@@ -469,6 +709,18 @@ export const applyOrganize = async (
469
709
  });
470
710
  }
471
711
  if (applyMove) {
712
+ const winner = await findCurrentMoveWinner(
713
+ { client, match },
714
+ movers,
715
+ accountConfigId,
716
+ messageId,
717
+ );
718
+ if (winner && winner.actionMailboxId !== predicate.actionMailboxId) {
719
+ // A more-recently-changed filter currently claims this message's
720
+ // move (RFC 034 Decision 3.2) — defer to it. The label above, if
721
+ // requested, has already applied; only the move is suppressed.
722
+ return;
723
+ }
472
724
  if (!moveService) {
473
725
  // An exclusive move was requested but this caller wired no move
474
726
  // service. Never silently pretend it applied — surface it as a