@remit/mailbox-service 0.0.36 → 0.0.38

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/mailbox-service",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,323 @@
1
+ /**
2
+ * RFC 039 Non-goals / issue #383: placement is meant to run once per message.
3
+ * #378 (issue #355) guarded `Message.category` against the same two re-entrant
4
+ * paths re-running `applyPostStoreSteps` on an already-processed message —
5
+ * `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
6
+ * — but did not guard `resolvePlacement`/`computePlacement`. Without a guard,
7
+ * a message the provider originally junked and a user later rescued by hand
8
+ * (never touched by Remit, so `movedByRemit` records nothing) gets
9
+ * `classifyPlacement` re-evaluated against the same demote signals that
10
+ * junked it in the first place, and can be silently moved right back.
11
+ *
12
+ * Each "already decided" test below presets `placementDecidedAt` on the
13
+ * fixture (as a genuine first pass would have left it) and feeds the
14
+ * re-entrant pass a body whose headers WOULD trigger a confident demote if
15
+ * `classifyPlacement` ran fresh — so a regression that drops the guard shows
16
+ * up as an unwanted move, not as a passing test relying on deterministic
17
+ * heuristics happening to agree.
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { Readable } from "node:stream";
22
+ import { describe, it } from "node:test";
23
+ import type {
24
+ AddressItem,
25
+ IAddressRepository,
26
+ IEnvelopeRepository,
27
+ IMailboxSpecialUseRepository,
28
+ IMessageRepository,
29
+ IThreadMessageRepository,
30
+ MessageItem,
31
+ UpdateMessageInput,
32
+ } from "@remit/data-ports";
33
+ import { MailboxSpecialUse } from "@remit/domain-enums";
34
+ import type { StorageService } from "@remit/storage-service";
35
+ import type { PlacementConfig } from "./body-sync.js";
36
+ import { BodySyncService } from "./body-sync.js";
37
+ import type { PlacementMoveService } from "./placement-move.js";
38
+ import type { IImapConnection } from "./types.js";
39
+
40
+ const MAILBOXES = {
41
+ inbox: { mailboxId: "mb-inbox", fullPath: "INBOX" },
42
+ junk: { mailboxId: "mb-junk", fullPath: "Junk" },
43
+ };
44
+
45
+ /**
46
+ * DKIM signing domain mismatches the From domain, dmarc=fail, sender
47
+ * untrusted, a provider-spam header present (any value) — exactly the
48
+ * deterministic signal set `classifyPlacement`'s demote branch (inbox → junk,
49
+ * HIGH bar) confidently acts on when the message currently sits in Inbox.
50
+ */
51
+ const DEMOTE_EML = Buffer.from(
52
+ [
53
+ "From: Support <support@evil-mimic.example>",
54
+ "To: me@example.com",
55
+ "Subject: Verify your account",
56
+ "Authentication-Results: mx.example.com; dmarc=fail",
57
+ "DKIM-Signature: v=1; a=rsa-sha256; d=relay.example.net; s=sel; b=xxx",
58
+ "X-Spam-Status: No, score=0.1",
59
+ "Content-Type: text/plain",
60
+ "",
61
+ "body",
62
+ ].join("\r\n"),
63
+ );
64
+
65
+ interface Harness {
66
+ service: BodySyncService;
67
+ message: MessageItem;
68
+ messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
69
+ moves: Array<{ messageId: string; destinationMailboxId: string }>;
70
+ }
71
+
72
+ const buildHarness = (
73
+ message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
74
+ retrieve: () => Promise<Buffer>,
75
+ ): Harness => {
76
+ const messageUpdates: Array<{
77
+ messageId: string;
78
+ input: UpdateMessageInput;
79
+ }> = [];
80
+ const moves: Array<{ messageId: string; destinationMailboxId: string }> = [];
81
+
82
+ const messageRow = {
83
+ uid: 1,
84
+ mailboxId: MAILBOXES.inbox.mailboxId,
85
+ ...message,
86
+ } as unknown as MessageItem;
87
+
88
+ const messageService = {
89
+ get: async () => messageRow,
90
+ update: async (messageId: string, input: UpdateMessageInput) => {
91
+ messageUpdates.push({ messageId, input });
92
+ Object.assign(messageRow, input);
93
+ },
94
+ } as unknown as IMessageRepository;
95
+
96
+ const threadMessageService = {
97
+ findAllByMessageId: async () => [
98
+ {
99
+ threadMessageId: "tm-1",
100
+ messageId: message.messageId,
101
+ mailboxId: messageRow.mailboxId,
102
+ sentDate: 1,
103
+ isRead: false,
104
+ isDeleted: false,
105
+ hasStars: false,
106
+ hasAttachment: false,
107
+ },
108
+ ],
109
+ update: async () => {},
110
+ } as unknown as IThreadMessageRepository;
111
+
112
+ const storageService = {
113
+ retrieve,
114
+ storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
115
+ storeMessageBodyStream: async () => ({
116
+ uri: `s3://bodies/${message.messageId}`,
117
+ }),
118
+ storeParsedBody: async () => {},
119
+ listBodyParts: async () => [],
120
+ } as unknown as StorageService;
121
+
122
+ const addressService = {
123
+ getAddress: async () => ({ flags: {} }) as unknown as AddressItem,
124
+ incrementInboundCount: async () => {},
125
+ } as unknown as IAddressRepository;
126
+
127
+ const envelopeService = {
128
+ listBodyParts: async () => [],
129
+ } as unknown as IEnvelopeRepository;
130
+
131
+ const mailboxSpecialUseService = {
132
+ findBySpecialUse: async (_accountId: string, specialUse: string) =>
133
+ specialUse === MailboxSpecialUse.Junk ? MAILBOXES.junk : null,
134
+ findInboxMailbox: async () => MAILBOXES.inbox,
135
+ } as unknown as IMailboxSpecialUseRepository;
136
+
137
+ const placementMoveService = {
138
+ moveMessage: async (
139
+ _accountConfigId: string,
140
+ messageId: string,
141
+ destinationMailboxId: string,
142
+ ) => {
143
+ moves.push({ messageId, destinationMailboxId });
144
+ messageRow.mailboxId = destinationMailboxId;
145
+ },
146
+ } as unknown as PlacementMoveService;
147
+
148
+ const placementConfig: PlacementConfig = {
149
+ mailboxSpecialUseService,
150
+ placementMoveService,
151
+ };
152
+
153
+ const service = new BodySyncService(
154
+ messageService,
155
+ storageService,
156
+ threadMessageService,
157
+ addressService,
158
+ envelopeService,
159
+ { info: () => {}, error: () => {} },
160
+ placementConfig,
161
+ );
162
+
163
+ return { service, message: messageRow, messageUpdates, moves };
164
+ };
165
+
166
+ const noSuchKeyError = () =>
167
+ Object.assign(new Error("missing"), { name: "NoSuchKey" });
168
+
169
+ describe("placement survives a re-entrant computePlacement pass (issue #383)", () => {
170
+ it("keeps a user-rescued message in Inbox through the NoSuchKey IMAP re-fetch", async () => {
171
+ const harness = buildHarness(
172
+ {
173
+ messageId: "m-1",
174
+ mailboxId: MAILBOXES.inbox.mailboxId,
175
+ bodyStorageKey: "s3://bodies/m-1",
176
+ movedByRemit: false,
177
+ placementDecidedAt: 500,
178
+ },
179
+ async () => {
180
+ throw noSuchKeyError();
181
+ },
182
+ );
183
+
184
+ const connection = {
185
+ openBox: async () => {},
186
+ fetchMessageBody: async () => DEMOTE_EML,
187
+ } as unknown as IImapConnection;
188
+
189
+ await harness.service.fetchAndGetBody(
190
+ "m-1",
191
+ "acc-1",
192
+ "cfg-1",
193
+ "INBOX",
194
+ async () => connection,
195
+ );
196
+
197
+ assert.deepEqual(
198
+ harness.moves,
199
+ [],
200
+ "an already-decided placement must not be re-evaluated, even though these headers would confidently demote if classifyPlacement ran fresh",
201
+ );
202
+ assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
203
+ assert.equal(harness.messageUpdates[0]?.input.movedByRemit, undefined);
204
+ assert.equal(harness.messageUpdates[0]?.input.placementVerdict, undefined);
205
+ });
206
+
207
+ it("keeps a user-rescued message in Inbox when syncBodies re-fetches with force", async () => {
208
+ const harness = buildHarness(
209
+ {
210
+ messageId: "m-1",
211
+ mailboxId: MAILBOXES.inbox.mailboxId,
212
+ bodyStorageKey: "s3://bodies/m-1",
213
+ movedByRemit: false,
214
+ placementDecidedAt: 500,
215
+ },
216
+ async () => {
217
+ throw new Error("force path must not retrieve from storage");
218
+ },
219
+ );
220
+
221
+ const connection = {
222
+ openBox: async () => {},
223
+ async *fetchMessageBodies(uids: number[]) {
224
+ for (const uid of uids) {
225
+ yield { uid, source: Readable.from([DEMOTE_EML]) };
226
+ }
227
+ },
228
+ } as unknown as IImapConnection;
229
+
230
+ const result = await harness.service.syncBodies(
231
+ ["m-1"],
232
+ "acc-1",
233
+ "cfg-1",
234
+ "INBOX",
235
+ async () => connection,
236
+ true,
237
+ );
238
+
239
+ assert.deepEqual(result.syncedMessageIds, ["m-1"]);
240
+ assert.deepEqual(
241
+ harness.moves,
242
+ [],
243
+ "a forced re-sync must not re-decide an already-decided placement",
244
+ );
245
+ assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
246
+ });
247
+
248
+ it("still evaluates and can confidently move a message that has never been placement-classified", async () => {
249
+ const harness = buildHarness(
250
+ {
251
+ messageId: "m-1",
252
+ mailboxId: MAILBOXES.inbox.mailboxId,
253
+ movedByRemit: false,
254
+ },
255
+ async () => {
256
+ throw new Error("no body stored yet; must not retrieve");
257
+ },
258
+ );
259
+
260
+ const connection = {
261
+ openBox: async () => {},
262
+ fetchMessageBody: async () => DEMOTE_EML,
263
+ } as unknown as IImapConnection;
264
+
265
+ await harness.service.fetchAndGetBody(
266
+ "m-1",
267
+ "acc-1",
268
+ "cfg-1",
269
+ "INBOX",
270
+ async () => connection,
271
+ );
272
+
273
+ assert.deepEqual(harness.moves, [
274
+ { messageId: "m-1", destinationMailboxId: MAILBOXES.junk.mailboxId },
275
+ ]);
276
+ assert.equal(harness.messageUpdates[0]?.input.movedByRemit, true);
277
+ assert.ok(
278
+ typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
279
+ "a genuine first evaluation must record placementDecidedAt",
280
+ );
281
+ });
282
+
283
+ it("continues to protect a message Remit itself already moved (movedByRemit: true), unchanged behavior", async () => {
284
+ const harness = buildHarness(
285
+ {
286
+ messageId: "m-1",
287
+ mailboxId: MAILBOXES.inbox.mailboxId,
288
+ bodyStorageKey: "s3://bodies/m-1",
289
+ movedByRemit: true,
290
+ // No placementDecidedAt — a legacy row synced before issue #383's
291
+ // guard existed. `classifyPlacement`'s own `movedByRemit` check
292
+ // must still hold on its own.
293
+ },
294
+ async () => {
295
+ throw noSuchKeyError();
296
+ },
297
+ );
298
+
299
+ const connection = {
300
+ openBox: async () => {},
301
+ fetchMessageBody: async () => DEMOTE_EML,
302
+ } as unknown as IImapConnection;
303
+
304
+ await harness.service.fetchAndGetBody(
305
+ "m-1",
306
+ "acc-1",
307
+ "cfg-1",
308
+ "INBOX",
309
+ async () => connection,
310
+ );
311
+
312
+ assert.deepEqual(
313
+ harness.moves,
314
+ [],
315
+ "movedByRemit must keep protecting a legacy row with no placementDecidedAt of its own",
316
+ );
317
+ assert.equal(harness.message.mailboxId, MAILBOXES.inbox.mailboxId);
318
+ assert.ok(
319
+ typeof harness.messageUpdates[0]?.input.placementDecidedAt === "number",
320
+ "the legacy row is backfilled with placementDecidedAt going forward, self-healing for future passes",
321
+ );
322
+ });
323
+ });
package/src/body-sync.ts CHANGED
@@ -74,6 +74,7 @@ type ThreadMessageCategory = ThreadMessageItem["category"];
74
74
  interface PlacementOutcome {
75
75
  verdict?: MessagePlacementVerdict;
76
76
  move?: { destinationMailboxId: string; destinationPath: string };
77
+ placementDecidedAt?: number;
77
78
  }
78
79
 
79
80
  /**
@@ -185,6 +186,19 @@ const hasDecidedCategory = (
185
186
  ): boolean =>
186
187
  category !== undefined && category !== MessageCategory.uncategorized;
187
188
 
189
+ /**
190
+ * Issue #383 (RFC 039 Non-goals): whether {@link BodySyncService.computePlacement}
191
+ * has already produced a verdict for this message — moved, left in place, or
192
+ * archived, confident or unsure alike. Absence means placement has genuinely
193
+ * never been evaluated. The same two re-entrant paths `hasDecidedCategory`
194
+ * guards (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force:
195
+ * true)`) also re-enter `computePlacement`; without this guard a message a
196
+ * user manually rescued (never touched by Remit, so `movedByRemit` never
197
+ * recorded anything) can be silently re-evaluated and moved right back.
198
+ */
199
+ const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
200
+ placementDecidedAt !== undefined;
201
+
188
202
  export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
189
203
  text: parsed.text ?? null,
190
204
  html: typeof parsed.html === "string" ? parsed.html : null,
@@ -867,6 +881,11 @@ export class BodySyncService {
867
881
  // this guard a re-entrant pass would let a *later* override silently
868
882
  // rewrite a category already decided on an earlier message, which is
869
883
  // exactly the churn RFC 030's GSI-safety argument forbids.
884
+ //
885
+ // `resolved.placementDecidedAt` (issue #383) guards the same two
886
+ // re-entrant paths for placement: `computePlacement` already declined to
887
+ // recompute a verdict once this field is set, so it is only ever present
888
+ // here on the pass that first decided it.
870
889
  const existingMessage = await this.messageService.get(messageId);
871
890
  const finalCategory = hasDecidedCategory(existingMessage.category)
872
891
  ? existingMessage.category
@@ -889,6 +908,9 @@ export class BodySyncService {
889
908
  ...(moved ? { movedByRemit: true } : {}),
890
909
  ...(resolved.verdict ? { placementVerdict: resolved.verdict } : {}),
891
910
  ...(filterMove ? { filterMove } : {}),
911
+ ...(resolved.placementDecidedAt
912
+ ? { placementDecidedAt: resolved.placementDecidedAt }
913
+ : {}),
892
914
  };
893
915
  await this.messageService.update(messageId, update);
894
916
  this.log.info({ messageId, storageKey: bodyRef.uri }, "Body stored");
@@ -1320,8 +1342,11 @@ export class BodySyncService {
1320
1342
  *
1321
1343
  * Returns a {@link PlacementOutcome}: a `verdict` to persist whenever Remit
1322
1344
  * decided to act (action != leave), confident and unsure alike, so the
1323
- * distribution is queryable on the message; and a `move` to enqueue only for
1324
- * a confident verdict.
1345
+ * distribution is queryable on the message; a `move` to enqueue only for a
1346
+ * confident verdict; and `placementDecidedAt` whenever a verdict — including
1347
+ * "leave" — was genuinely computed (issue #383), so a re-entrant call short-
1348
+ * circuits instead of re-deciding a placement the user may have since
1349
+ * overridden by hand.
1325
1350
  *
1326
1351
  * Always logs a structured verdict line for confident, actionable verdicts so
1327
1352
  * the real distribution is observable on a live mailbox.
@@ -1334,7 +1359,7 @@ export class BodySyncService {
1334
1359
  * alertable field instead of failing the surrounding message store. The empty
1335
1360
  * {@link PlacementOutcome} means "no action", whether Remit genuinely decided
1336
1361
  * to leave the message alone or placement itself failed; the alert log is
1337
- * what distinguishes the latter.
1362
+ * what distinguishes the latter, and neither case marks the message decided.
1338
1363
  */
1339
1364
  private async resolvePlacement(
1340
1365
  messageId: string,
@@ -1378,6 +1403,16 @@ export class BodySyncService {
1378
1403
  const { mailboxSpecialUseService } = placementConfig;
1379
1404
 
1380
1405
  const message = await this.messageService.get(messageId);
1406
+
1407
+ // Issue #383: placement is meant to run once per message (RFC 039
1408
+ // Non-goals). Once a verdict has EVER been decided for this message —
1409
+ // moved, left in place, or archived — a re-entrant pass (the `NoSuchKey`
1410
+ // fallback in `fetchAndGetBody`, `syncBodies(..., force: true)`) must not
1411
+ // recompute it: a message a user has since moved by hand (which never
1412
+ // touches `movedByRemit`) would otherwise be silently re-evaluated
1413
+ // against the same signals that placed it in the first place.
1414
+ if (hasDecidedPlacement(message.placementDecidedAt)) return {};
1415
+
1381
1416
  const junkMailbox = await mailboxSpecialUseService.findBySpecialUse(
1382
1417
  accountId,
1383
1418
  MailboxSpecialUse.Junk,
@@ -1421,14 +1456,16 @@ export class BodySyncService {
1421
1456
  // audit record of its own. `flags.autoArchive` (issue #300) is a distinct,
1422
1457
  // lower-priority filing preference: it only files a message away when
1423
1458
  // `blocked`/DKIM/DMARC had nothing to say, never overriding a confident
1424
- // junk/inbox verdict computed above.
1459
+ // junk/inbox verdict computed above. Still marked decided (issue #383):
1460
+ // "leave" is itself a verdict, not "not yet evaluated".
1425
1461
  if (verdict.action === "leave") {
1426
- return this.resolveAutoArchive(
1462
+ const outcome = await this.resolveAutoArchive(
1427
1463
  mailboxSpecialUseService,
1428
1464
  message,
1429
1465
  accountId,
1430
1466
  signals.autoArchive,
1431
1467
  );
1468
+ return { ...outcome, placementDecidedAt: Date.now() };
1432
1469
  }
1433
1470
 
1434
1471
  // Audit verdict — recorded for every actionable verdict (both
@@ -1452,12 +1489,12 @@ export class BodySyncService {
1452
1489
  // Only a confident verdict moves mail. An unsure verdict is recorded
1453
1490
  // but never enqueues a move.
1454
1491
  if (verdict.confidence !== "confident") {
1455
- return { verdict: audit };
1492
+ return { verdict: audit, placementDecidedAt: audit.decidedAt };
1456
1493
  }
1457
1494
 
1458
1495
  const target =
1459
1496
  verdict.action === "move-to-inbox" ? inboxMailbox : junkMailbox;
1460
- if (!target) return { verdict: audit };
1497
+ if (!target) return { verdict: audit, placementDecidedAt: audit.decidedAt };
1461
1498
 
1462
1499
  // Structured verdict line — emitted for confident, actionable verdicts
1463
1500
  // so the real verdict distribution is observable on a live mailbox.
@@ -1480,6 +1517,7 @@ export class BodySyncService {
1480
1517
  destinationMailboxId: target.mailboxId,
1481
1518
  destinationPath: target.fullPath,
1482
1519
  },
1520
+ placementDecidedAt: audit.decidedAt,
1483
1521
  };
1484
1522
  }
1485
1523
 
@@ -228,14 +228,17 @@ describe("cosineSimilarity", () => {
228
228
  });
229
229
 
230
230
  describe("selectMoveWinner", () => {
231
- const filter = (filterId: string, ruleChangedAt: number): FilterItem =>
232
- ({ filterId, ruleChangedAt }) as FilterItem;
231
+ const filter = (
232
+ filterId: string,
233
+ actionChangedAt: number,
234
+ ruleChangedAt = actionChangedAt,
235
+ ): FilterItem => ({ filterId, actionChangedAt, ruleChangedAt }) as FilterItem;
233
236
 
234
237
  it("returns undefined with no candidates", () => {
235
238
  assert.equal(selectMoveWinner([]), undefined);
236
239
  });
237
240
 
238
- it("picks the most-recently-changed filter", () => {
241
+ it("picks the most-recently action-changed filter", () => {
239
242
  const winner = selectMoveWinner([
240
243
  filter("a", 100),
241
244
  filter("b", 300),
@@ -244,7 +247,7 @@ describe("selectMoveWinner", () => {
244
247
  assert.equal(winner?.filterId, "b");
245
248
  });
246
249
 
247
- it("tie-breaks on filterId when ruleChangedAt is identical", () => {
250
+ it("tie-breaks on filterId when actionChangedAt is identical", () => {
248
251
  const winner = selectMoveWinner([
249
252
  filter("a", 100),
250
253
  filter("c", 100),
@@ -252,6 +255,23 @@ describe("selectMoveWinner", () => {
252
255
  ]);
253
256
  assert.equal(winner?.filterId, "c");
254
257
  });
258
+
259
+ it("ignores a ruleChangedAt-only bump — extending scope/expiry alone must not promote a filter to move-winner (reader #384)", () => {
260
+ // filter A's predicate/action last changed at 10:00 (actionChangedAt).
261
+ // filter B was created at 09:00 and never had its predicate/action
262
+ // touched since, but a user extended its expiry at 11:00 — bumping only
263
+ // its ruleChangedAt (RFC 034 Decision 3.2 / #294), not what it matches or
264
+ // does. B must not out-rank A.
265
+ const filterA = filter("filter-a", 10_00);
266
+ const filterB = filter("filter-b", 9_00, 11_00);
267
+
268
+ const winner = selectMoveWinner([filterA, filterB]);
269
+ assert.equal(
270
+ winner?.filterId,
271
+ "filter-a",
272
+ "A still wins: its actionChangedAt (10:00) beats B's (09:00), even though B's ruleChangedAt (11:00) is later",
273
+ );
274
+ });
255
275
  });
256
276
 
257
277
  describe("buildMatchText", () => {
@@ -134,10 +134,14 @@ export const cosineSimilarity = (
134
134
  };
135
135
 
136
136
  /**
137
- * The move a message ends in when several filters matched: the most-recently
138
- * *changed* filter wins (RFC 034 Decision 3.2), tie-broken on `filterId` for the
139
- * unreachable identical-timestamp case. `ruleChangedAt` not `updatedAt` is
140
- * the signal, so a cosmetic rename never flips an exclusive move.
137
+ * The move a message ends in when several filters matched: the filter whose
138
+ * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
139
+ * tie-broken on `filterId` for the unreachable identical-timestamp case.
140
+ * `actionChangedAt` not `ruleChangedAt` is the signal: `ruleChangedAt` also
141
+ * bumps on a scope/expiry-only edit (reader #266), which changes a filter's
142
+ * lifecycle, not what it matches or does, and must not reorder exclusive-move
143
+ * precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
144
+ * flips an exclusive move either.
141
145
  */
142
146
  export const selectMoveWinner = (
143
147
  candidates: readonly FilterItem[],
@@ -148,12 +152,12 @@ export const selectMoveWinner = (
148
152
  winner = candidate;
149
153
  continue;
150
154
  }
151
- if (candidate.ruleChangedAt > winner.ruleChangedAt) {
155
+ if (candidate.actionChangedAt > winner.actionChangedAt) {
152
156
  winner = candidate;
153
157
  continue;
154
158
  }
155
159
  if (
156
- candidate.ruleChangedAt === winner.ruleChangedAt &&
160
+ candidate.actionChangedAt === winner.actionChangedAt &&
157
161
  candidate.filterId > winner.filterId
158
162
  ) {
159
163
  winner = candidate;