@remit/backend 0.0.79 → 0.0.81

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.79",
3
+ "version": "0.0.81",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -44,11 +44,11 @@ describe("predicateFromInput (move back-apply accepted)", () => {
44
44
  });
45
45
  });
46
46
 
47
- // previewOrganize rejects a body-content (HasWords) clause by throwing
48
- // BadRequestError (see service/organize.test.ts for the throw site). Proving
49
- // the 4xx actually reaches the wire — not just that the right class is
50
- // thrown — means proving the shared error handler maps it, since a plain
51
- // Error would otherwise fall through to a 500 (reader #457).
47
+ // The matcher returns a body-content (HasWords) refusal as a result (see
48
+ // service/organize.test.ts); previewOrganize is the boundary that words it as a
49
+ // BadRequestError. Proving the 4xx actually reaches the wire — not just that the
50
+ // right class is raised — means proving the shared error handler maps it, since
51
+ // a plain Error would otherwise fall through to a 500 (reader #457).
52
52
  describe("previewOrganize rejected-rule response (reader #457)", () => {
53
53
  it("maps a rejected HasWords clause to a 400 naming the reason, not a 500", async () => {
54
54
  const error = new BadRequestError(
@@ -5,7 +5,7 @@ import type {
5
5
  OrganizeJobResponse,
6
6
  OrganizePreviewResponse,
7
7
  } from "@remit/api-openapi-types";
8
- import { NotFoundError } from "@remit/data-ports/errors";
8
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
9
9
  import { logger } from "@remit/logger-lambda";
10
10
  import type { APIGatewayProxyEvent } from "aws-lambda";
11
11
  import { env } from "expect-env";
@@ -157,18 +157,19 @@ export const OrganizeOperations: Record<
157
157
  const client = await getClient();
158
158
  await assertAccount(client, accountId, accountConfigId, "read");
159
159
 
160
- const { messageIds, semanticUnavailable } = await matchOrganize(
160
+ const result = await matchOrganize(
161
161
  buildOrganizeMatchDeps(client),
162
162
  accountConfigId,
163
163
  predicateFromInput(input),
164
164
  ORGANIZE_MATCH_LIMIT,
165
165
  );
166
+ if (result.rejected) throw new BadRequestError(result.rejected.message);
166
167
 
167
168
  const response: OrganizePreviewResponse = {
168
- matchedCount: messageIds.length,
169
- messageIds,
169
+ matchedCount: result.messageIds.length,
170
+ messageIds: result.messageIds,
170
171
  };
171
- if (semanticUnavailable) response.semanticUnavailable = true;
172
+ if (result.semanticUnavailable) response.semanticUnavailable = true;
172
173
  return response;
173
174
  },
174
175
  };
@@ -109,6 +109,15 @@ const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
109
109
  outboxMessageId: string,
110
110
  status: OutboxMessageItem["status"],
111
111
  ) => repository.update(accountConfigId, outboxMessageId, { status }),
112
+ updateIfStatus: async (
113
+ accountConfigId: string,
114
+ outboxMessageId: string,
115
+ expected: OutboxMessageItem["status"],
116
+ input: UpdateOutboxMessageInput,
117
+ ) => {
118
+ if (mustGet(outboxMessageId).status !== expected) return null;
119
+ return repository.update(accountConfigId, outboxMessageId, input);
120
+ },
112
121
  markSent: async (
113
122
  accountConfigId: string,
114
123
  outboxMessageId: string,
@@ -453,12 +462,17 @@ describe("a send whose enqueue fails (#845.8)", () => {
453
462
  assert.equal(response.statusCode, 500);
454
463
  });
455
464
 
456
- it("leaves the row a draft, not stranded at queued", async () => {
465
+ it("settles the row at failed, not stranded at queued and not back at draft", async () => {
457
466
  const { outboxMessageId } = await draftAgainstQueue(
458
467
  refusingSqsClient().client,
459
468
  );
460
469
 
461
- assert.equal(await statusOf(outboxMessageId), OutboxMessageStatus.draft);
470
+ // `queued` is the dead end this suite exists for. `draft` is the other
471
+ // wrong answer: it is inside the SMTP worker's send fence, and an error
472
+ // from SQS does not prove the event was refused — one that landed anyway
473
+ // would send a row the user can still send by hand. `failed` is outside
474
+ // the fence and, since #933, still editable and sendable.
475
+ assert.equal(await statusOf(outboxMessageId), OutboxMessageStatus.failed);
462
476
  });
463
477
 
464
478
  it("still lets the user discard what could not be queued", async () => {
@@ -5,7 +5,7 @@ import type {
5
5
  FilterAnchorItem,
6
6
  FilterItem,
7
7
  } from "@remit/data-ports";
8
- import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
8
+ import { NotFoundError } from "@remit/data-ports/errors";
9
9
  import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
10
10
  import type {
11
11
  AnchorPayload,
@@ -16,9 +16,11 @@ import { createMemoryVectorStore } from "@remit/search-service";
16
16
  import type { RemitClient } from "./data-client.js";
17
17
  import {
18
18
  applyOrganize,
19
+ BODY_CONTENT_REJECTION_MESSAGE,
19
20
  matchOrganize,
20
21
  type OrganizeCandidate,
21
22
  type OrganizeMatchDeps,
23
+ type OrganizeMatched,
22
24
  type OrganizePredicate,
23
25
  } from "./organize.js";
24
26
  import {
@@ -82,6 +84,23 @@ const predicate = (
82
84
  ...over,
83
85
  });
84
86
 
87
+ /**
88
+ * Run the matcher and assert it accepted the predicate, so every test that only
89
+ * cares about the matched set reads the accepted arm directly. A rejection is a
90
+ * result, not a throw (reader #463), so without this the two would be silently
91
+ * interchangeable at the call site.
92
+ */
93
+ const matchAccepted = async (
94
+ ...args: Parameters<typeof matchOrganize>
95
+ ): Promise<OrganizeMatched> => {
96
+ const result = await matchOrganize(...args);
97
+ assert.ok(
98
+ result.rejected === null,
99
+ `expected an accepted match, got: ${result.rejected?.message}`,
100
+ );
101
+ return result;
102
+ };
103
+
85
104
  /** A standing filter fixture — the "other" filters the precedence check reads. */
86
105
  const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
87
106
  filterId: "filter-other",
@@ -338,7 +357,7 @@ describe("matchOrganize", () => {
338
357
  bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
339
358
  ]);
340
359
 
341
- const { messageIds, semanticUnavailable } = await matchOrganize(
360
+ const { messageIds, semanticUnavailable } = await matchAccepted(
342
361
  matchDeps(store),
343
362
  ACCOUNT_CONFIG_ID,
344
363
  predicate({ actionLabelId: "lbl-1" }),
@@ -352,7 +371,7 @@ describe("matchOrganize", () => {
352
371
  const store = createMemoryVectorStore();
353
372
  await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
354
373
 
355
- const { messageIds } = await matchOrganize(
374
+ const { messageIds } = await matchAccepted(
356
375
  matchDeps(store),
357
376
  ACCOUNT_CONFIG_ID,
358
377
  {
@@ -377,7 +396,7 @@ describe("matchOrganize", () => {
377
396
  }),
378
397
  ]);
379
398
 
380
- const { messageIds } = await matchOrganize(
399
+ const { messageIds } = await matchAccepted(
381
400
  matchDeps(store),
382
401
  ACCOUNT_CONFIG_ID,
383
402
  {
@@ -396,7 +415,7 @@ describe("matchOrganize", () => {
396
415
  candidate("msg-2", { subject: "Newsletter" }),
397
416
  ]);
398
417
 
399
- const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
418
+ const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
400
419
  ...predicate(),
401
420
  anchorMessageId: "None",
402
421
  literalClauses: [{ field: "Subject", value: "reservation" }],
@@ -453,7 +472,7 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
453
472
  },
454
473
  };
455
474
 
456
- const { messageIds } = await matchOrganize(
475
+ const { messageIds } = await matchAccepted(
457
476
  deps,
458
477
  ACCOUNT_CONFIG_ID,
459
478
  predicate(),
@@ -478,7 +497,7 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
478
497
  // No persisted FilterAnchor names this anchorMessageId — an ad hoc "all
479
498
  // like these" widen over a bare message selection, never tied to a
480
499
  // standing filter.
481
- const { messageIds } = await matchOrganize(
500
+ const { messageIds } = await matchAccepted(
482
501
  matchDeps(store),
483
502
  ACCOUNT_CONFIG_ID,
484
503
  predicate(),
@@ -507,7 +526,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
507
526
  candidate("msg-3", { subject: "Table reservation confirmed" }),
508
527
  ]);
509
528
 
510
- const { messageIds, semanticUnavailable } = await matchOrganize(
529
+ const { messageIds, semanticUnavailable } = await matchAccepted(
511
530
  deps,
512
531
  ACCOUNT_CONFIG_ID,
513
532
  {
@@ -526,36 +545,51 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
526
545
  );
527
546
  });
528
547
 
529
- it("rejects a body-content (HasWords) clause as a 400 rather than matching it against a preview", async () => {
548
+ it("refuses a body-content (HasWords) clause as a result rather than matching it against a preview", async () => {
530
549
  const deps = vectorlessDeps([
531
550
  candidate("msg-1", { subject: "Dinner reservation" }),
532
551
  ]);
533
552
 
534
- await assert.rejects(
535
- () =>
536
- matchOrganize(deps, ACCOUNT_CONFIG_ID, {
537
- ...predicate(),
538
- anchorMessageId: "None",
539
- literalClauses: [{ field: "HasWords", value: "invoice" }],
540
- }),
541
- (error: unknown) => {
542
- assert.ok(error instanceof BadRequestError);
543
- assert.equal(error.statusCode, 400);
544
- assert.match(error.message, /HasWords/);
545
- return true;
553
+ const result = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
554
+ ...predicate(),
555
+ anchorMessageId: "None",
556
+ literalClauses: [{ field: "HasWords", value: "invoice" }],
557
+ });
558
+
559
+ assert.deepEqual(
560
+ result.rejected,
561
+ {
562
+ reason: "BodyContentWithoutVectorPipeline",
563
+ message: BODY_CONTENT_REJECTION_MESSAGE,
546
564
  },
547
565
  "the vector-free literal path must not silently narrow a body match to a preview",
548
566
  );
549
567
  assert.equal(deps.semanticUsed(), false);
550
568
  });
551
569
 
570
+ // The refusal is the same whichever way the vector-free path is reached, so
571
+ // an anchored predicate that degrades onto it cannot land in the corpus scan
572
+ // with a body clause it can only mis-evaluate (reader #463).
573
+ it("refuses a body-content clause reached through the degraded widen fallback", async () => {
574
+ const deps = vectorlessDeps([
575
+ candidate("msg-1", { subject: "Dinner reservation" }),
576
+ ]);
577
+
578
+ const result = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
579
+ ...predicate(),
580
+ literalClauses: [{ field: "HasWords", value: "invoice" }],
581
+ });
582
+
583
+ assert.equal(result.rejected?.reason, "BodyContentWithoutVectorPipeline");
584
+ });
585
+
552
586
  it("degrades an anchor+clauses widen to the literal matches, flagged, instead of crashing", async () => {
553
587
  const deps = vectorlessDeps([
554
588
  candidate("msg-1", { subject: "Dinner reservation" }),
555
589
  candidate("msg-2", { subject: "Weekly newsletter" }),
556
590
  ]);
557
591
 
558
- const { messageIds, semanticUnavailable } = await matchOrganize(
592
+ const { messageIds, semanticUnavailable } = await matchAccepted(
559
593
  deps,
560
594
  ACCOUNT_CONFIG_ID,
561
595
  {
@@ -571,7 +605,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
571
605
  it("degrades an anchor-only widen to an empty flagged result instead of crashing", async () => {
572
606
  const deps = vectorlessDeps([candidate("msg-1"), candidate("msg-2")]);
573
607
 
574
- const { messageIds, semanticUnavailable } = await matchOrganize(
608
+ const { messageIds, semanticUnavailable } = await matchAccepted(
575
609
  deps,
576
610
  ACCOUNT_CONFIG_ID,
577
611
  predicate(),
@@ -623,7 +657,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
623
657
  },
624
658
  };
625
659
 
626
- const { messageIds, semanticUnavailable } = await matchOrganize(
660
+ const { messageIds, semanticUnavailable } = await matchAccepted(
627
661
  deps,
628
662
  ACCOUNT_CONFIG_ID,
629
663
  predicate(),
@@ -684,12 +718,12 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
684
718
 
685
719
  // The preview and the apply share the same matcher — the previewed set is
686
720
  // exactly what gets applied.
687
- const previewed = await matchOrganize(
721
+ const previewed = await matchAccepted(
688
722
  matchDeps(store),
689
723
  ACCOUNT_CONFIG_ID,
690
724
  p,
691
725
  );
692
- const applied = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
726
+ const applied = await matchAccepted(matchDeps(store), ACCOUNT_CONFIG_ID, p);
693
727
  assert.deepEqual(previewed, applied);
694
728
 
695
729
  const tracked = trackingClient();
@@ -723,7 +757,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
723
757
  await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
724
758
  const p = predicate({ actionMailboxId: "mbox-target" });
725
759
 
726
- const { messageIds: matched } = await matchOrganize(
760
+ const { messageIds: matched } = await matchAccepted(
727
761
  matchDeps(store),
728
762
  ACCOUNT_CONFIG_ID,
729
763
  p,
@@ -749,7 +783,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
749
783
  ]);
750
784
  const p = predicate({ actionMailboxId: "mbox-target" });
751
785
 
752
- const { messageIds: matched } = await matchOrganize(
786
+ const { messageIds: matched } = await matchAccepted(
753
787
  matchDeps(store),
754
788
  ACCOUNT_CONFIG_ID,
755
789
  p,
@@ -799,7 +833,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
799
833
  actionMailboxId: "mbox-target",
800
834
  });
801
835
 
802
- const { messageIds: matched } = await matchOrganize(
836
+ const { messageIds: matched } = await matchAccepted(
803
837
  matchDeps(store),
804
838
  ACCOUNT_CONFIG_ID,
805
839
  p,
@@ -834,7 +868,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
834
868
  await store.upsert(matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)));
835
869
  const p = predicate({ actionMailboxId: "mbox-target" });
836
870
 
837
- const { messageIds: matched } = await matchOrganize(
871
+ const { messageIds: matched } = await matchAccepted(
838
872
  matchDeps(store),
839
873
  ACCOUNT_CONFIG_ID,
840
874
  p,
@@ -897,7 +931,7 @@ describe("applyOrganize resolves move precedence against current Active filters
897
931
  literalClauses: [{ field: "Subject", value: "reservation" }],
898
932
  });
899
933
 
900
- const { messageIds: matched } = await matchOrganize(
934
+ const { messageIds: matched } = await matchAccepted(
901
935
  matchDeps(store),
902
936
  ACCOUNT_CONFIG_ID,
903
937
  p,
@@ -946,7 +980,7 @@ describe("applyOrganize resolves move precedence against current Active filters
946
980
  literalClauses: [{ field: "Subject", value: "reservation" }],
947
981
  });
948
982
 
949
- const { messageIds: matched } = await matchOrganize(
983
+ const { messageIds: matched } = await matchAccepted(
950
984
  matchDeps(store),
951
985
  ACCOUNT_CONFIG_ID,
952
986
  p,
@@ -998,7 +1032,7 @@ describe("applyOrganize resolves move precedence against current Active filters
998
1032
  updatedAt: 0,
999
1033
  };
1000
1034
 
1001
- const { messageIds: matched } = await matchOrganize(
1035
+ const { messageIds: matched } = await matchAccepted(
1002
1036
  matchDeps(store),
1003
1037
  ACCOUNT_CONFIG_ID,
1004
1038
  p,
@@ -1090,7 +1124,7 @@ describe("back-apply repairs a drifted anchor the way index-time matching does (
1090
1124
  ]);
1091
1125
  const deps = currentModelDeps(store, [staleAnchor("filter-a")]);
1092
1126
 
1093
- const { messageIds } = await matchOrganize(
1127
+ const { messageIds } = await matchAccepted(
1094
1128
  deps,
1095
1129
  ACCOUNT_CONFIG_ID,
1096
1130
  predicate(),
@@ -1126,7 +1160,7 @@ describe("back-apply repairs a drifted anchor the way index-time matching does (
1126
1160
  });
1127
1161
  const drifted = staleAnchor("filter-newer-semantic");
1128
1162
 
1129
- const { messageIds: matched } = await matchOrganize(
1163
+ const { messageIds: matched } = await matchAccepted(
1130
1164
  matchDeps(store),
1131
1165
  ACCOUNT_CONFIG_ID,
1132
1166
  p,
@@ -1232,7 +1266,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
1232
1266
  candidate("msg-3", {}),
1233
1267
  ]);
1234
1268
 
1235
- const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
1269
+ const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
1236
1270
  ...predicate(),
1237
1271
  anchorMessageId: "None",
1238
1272
  literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
@@ -1248,7 +1282,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
1248
1282
  candidate("msg-3", { from: "attacker@github.com.evil.example" }),
1249
1283
  ]);
1250
1284
 
1251
- const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
1285
+ const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
1252
1286
  ...predicate(),
1253
1287
  anchorMessageId: "None",
1254
1288
  literalClauses: [{ field: "FromDomain", value: "github.com" }],
@@ -1270,13 +1304,13 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
1270
1304
  bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
1271
1305
  ]);
1272
1306
 
1273
- const byListId = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
1307
+ const byListId = await matchAccepted(matchDeps(store), ACCOUNT_CONFIG_ID, {
1274
1308
  ...predicate(),
1275
1309
  literalClauses: [{ field: "ListId", value: "actions.github.com" }],
1276
1310
  });
1277
1311
  assert.deepEqual(byListId.messageIds, ["msg-1"]);
1278
1312
 
1279
- const byFromDomain = await matchOrganize(
1313
+ const byFromDomain = await matchAccepted(
1280
1314
  matchDeps(store),
1281
1315
  ACCOUNT_CONFIG_ID,
1282
1316
  {
@@ -1299,8 +1333,8 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
1299
1333
  literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
1300
1334
  });
1301
1335
 
1302
- const previewed = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
1303
- const applied = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
1336
+ const previewed = await matchAccepted(deps, ACCOUNT_CONFIG_ID, p);
1337
+ const applied = await matchAccepted(deps, ACCOUNT_CONFIG_ID, p);
1304
1338
  assert.deepEqual(previewed, applied);
1305
1339
  assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
1306
1340
 
@@ -5,7 +5,7 @@ import type {
5
5
  IFilterAnchorRepository,
6
6
  OrganizeJobRequestItem,
7
7
  } from "@remit/data-ports";
8
- import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
8
+ import { NotFoundError } from "@remit/data-ports/errors";
9
9
  import { FilterClauseField, FilterState } from "@remit/domain-enums";
10
10
  import { logger } from "@remit/logger-lambda";
11
11
  import {
@@ -154,12 +154,32 @@ export interface OrganizeMatchDeps {
154
154
  filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig" | "put">;
155
155
  }
156
156
 
157
+ /**
158
+ * Why the matcher refused a predicate. A refusal is an expected outcome of
159
+ * asking for a rule this deployment cannot evaluate, not a fault: the HTTP
160
+ * boundary words it as a 400 and the back-apply worker fails the job row on it,
161
+ * neither of which may treat it as the infrastructure failure a throw would
162
+ * make it (reader #463).
163
+ */
164
+ export interface OrganizeRejection {
165
+ reason: "BodyContentWithoutVectorPipeline";
166
+ message: string;
167
+ }
168
+
157
169
  /** The matched ids plus whether the semantic widen was skipped as unavailable. */
158
- export interface OrganizeMatchResult {
170
+ export interface OrganizeMatched {
171
+ rejected: null;
159
172
  messageIds: string[];
160
173
  semanticUnavailable: boolean;
161
174
  }
162
175
 
176
+ /** The predicate was refused: nothing matched, and nothing may be applied. */
177
+ export interface OrganizeMatchRejected {
178
+ rejected: OrganizeRejection;
179
+ }
180
+
181
+ export type OrganizeMatchResult = OrganizeMatched | OrganizeMatchRejected;
182
+
163
183
  const hasAnchor = (predicate: OrganizePredicate): boolean =>
164
184
  predicate.anchorMessageId !== NO_ACTION && predicate.anchorMessageId !== "";
165
185
 
@@ -334,18 +354,21 @@ const matchSemantic = async (
334
354
  * happened to be indexed. Body-content matching therefore requires the widen
335
355
  * (vector) path — {@link matchSemantic} reconstructs body text from chunk
336
356
  * previews there. This guard keeps the two matchers from diverging silently: a
337
- * body-content clause reaching the vector-free path is rejected instead of
357
+ * body-content clause reaching the vector-free path is refused instead of
338
358
  * returning a wrong set.
339
359
  */
340
- const assertNoBodyContentClause = (
360
+ export const BODY_CONTENT_REJECTION_MESSAGE =
361
+ "Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path";
362
+
363
+ const bodyContentRejection = (
341
364
  clauses: OrganizePredicate["literalClauses"],
342
- ): void => {
343
- if (clauses.some((clause) => clause.field === FilterClauseField.HasWords)) {
344
- throw new BadRequestError(
345
- "Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path",
346
- );
347
- }
348
- };
365
+ ): OrganizeRejection | null =>
366
+ clauses.some((clause) => clause.field === FilterClauseField.HasWords)
367
+ ? {
368
+ reason: "BodyContentWithoutVectorPipeline",
369
+ message: BODY_CONTENT_REJECTION_MESSAGE,
370
+ }
371
+ : null;
349
372
 
350
373
  /**
351
374
  * The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
@@ -353,7 +376,7 @@ const assertNoBodyContentClause = (
353
376
  * predicate and as the degraded fallback when a widen is requested on a
354
377
  * deployment without the vector pipeline. Serves `From`/`Subject` clauses at
355
378
  * full fidelity from the core thread rows; body-content (`HasWords`) clauses are
356
- * rejected up front (see {@link assertNoBodyContentClause}).
379
+ * refused before this runs (see {@link bodyContentRejection}).
357
380
  */
358
381
  const matchLiteral = async (
359
382
  deps: OrganizeMatchDeps,
@@ -362,7 +385,6 @@ const matchLiteral = async (
362
385
  limit: number,
363
386
  ): Promise<string[]> => {
364
387
  const clauses = predicate.literalClauses;
365
- assertNoBodyContentClause(clauses);
366
388
  const candidates = await deps.listAccountFilterMessages(
367
389
  accountConfigId,
368
390
  limit,
@@ -395,6 +417,9 @@ const matchLiteral = async (
395
417
  * capability-absence is absorbed exactly as `/search/semantic` absorbs it:
396
418
  * the literal matches are returned (empty for an anchor-only predicate) with
397
419
  * `semanticUnavailable` set, never a 500. Any other failure propagates.
420
+ * - A predicate the vector-free path cannot evaluate (a body-content clause,
421
+ * see {@link bodyContentRejection}) comes back as a rejection, so a caller
422
+ * can tell a refused rule from a broken deployment (reader #463).
398
423
  */
399
424
  export const matchOrganize = async (
400
425
  deps: OrganizeMatchDeps,
@@ -405,17 +430,19 @@ export const matchOrganize = async (
405
430
  const anchored = hasAnchor(predicate);
406
431
  const clauses = predicate.literalClauses;
407
432
  if (!anchored && clauses.length === 0) {
408
- return { messageIds: [], semanticUnavailable: false };
433
+ return { rejected: null, messageIds: [], semanticUnavailable: false };
409
434
  }
410
435
 
411
436
  if (!anchored) {
437
+ const rejection = bodyContentRejection(clauses);
438
+ if (rejection) return { rejected: rejection };
412
439
  const messageIds = await matchLiteral(
413
440
  deps,
414
441
  accountConfigId,
415
442
  predicate,
416
443
  limit,
417
444
  );
418
- return { messageIds, semanticUnavailable: false };
445
+ return { rejected: null, messageIds, semanticUnavailable: false };
419
446
  }
420
447
 
421
448
  try {
@@ -425,22 +452,28 @@ export const matchOrganize = async (
425
452
  predicate,
426
453
  limit,
427
454
  );
428
- return { messageIds: semanticIds ?? [], semanticUnavailable: false };
455
+ return {
456
+ rejected: null,
457
+ messageIds: semanticIds ?? [],
458
+ semanticUnavailable: false,
459
+ };
429
460
  } catch (error) {
430
461
  if (!noteSemanticCapabilityAbsence(error)) throw error;
431
462
  // The widen cannot run on this deployment. Fall back to the literal
432
463
  // clauses over the corpus — nothing when the predicate is anchor-only —
433
464
  // and flag the absence so the client can say so.
434
465
  if (clauses.length === 0) {
435
- return { messageIds: [], semanticUnavailable: true };
466
+ return { rejected: null, messageIds: [], semanticUnavailable: true };
436
467
  }
468
+ const rejection = bodyContentRejection(clauses);
469
+ if (rejection) return { rejected: rejection };
437
470
  const messageIds = await matchLiteral(
438
471
  deps,
439
472
  accountConfigId,
440
473
  predicate,
441
474
  limit,
442
475
  );
443
- return { messageIds, semanticUnavailable: true };
476
+ return { rejected: null, messageIds, semanticUnavailable: true };
444
477
  }
445
478
  };
446
479
 
@@ -480,7 +513,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
480
513
  * only a truncated `snippet`, and matching
481
514
  * a body-content clause against a preview would silently diverge from the live
482
515
  * index-time filter's full-body match. Body-content (`HasWords`) clauses are
483
- * rejected before this is read (see {@link assertNoBodyContentClause}), so the
516
+ * rejected before this is read (see {@link bodyContentRejection}), so the
484
517
  * empty `text` is never matched against.
485
518
  */
486
519
  const listAccountFilterMessagesFromClient =