@openparachute/vault 0.7.0-rc.2 → 0.7.0-rc.3

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/core/src/mcp.ts CHANGED
@@ -5,7 +5,8 @@ import * as noteOps from "./notes.js";
5
5
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
6
6
  import { QueryError } from "./query-operators.js";
7
7
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
8
- import { collectUnknownTagWarnings, type QueryWarning } from "./query-warnings.js";
8
+ import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
9
+ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
9
10
  import * as linkOps from "./links.js";
10
11
  import * as tagSchemaOps from "./tag-schemas.js";
11
12
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -243,7 +244,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
243
244
  Response shape (vault#550 — three variants, pick by what you passed):
244
245
  - Default (no \`cursor\`, no warnings): a bare array of notes.
245
246
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
246
- - Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.`,
247
+ - Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
248
+
249
+ \`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.`,
247
250
  inputSchema: {
248
251
  type: "object",
249
252
  properties: {
@@ -297,7 +300,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
297
300
  ],
298
301
  description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
299
302
  },
300
- search: { type: "string", description: "Full-text search query" },
303
+ search: {
304
+ type: "string",
305
+ description:
306
+ 'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
307
+ },
308
+ search_mode: {
309
+ type: "string",
310
+ enum: [...SEARCH_MODES],
311
+ description:
312
+ 'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
313
+ },
301
314
  metadata: {
302
315
  type: "object",
303
316
  description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
@@ -328,7 +341,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
328
341
  required: ["note_id"],
329
342
  description: "Scope results to notes within N hops of an anchor note",
330
343
  },
331
- sort: { type: "string", enum: ["asc", "desc"], description: "Sort by created_at" },
344
+ sort: {
345
+ type: "string",
346
+ enum: ["asc", "desc"],
347
+ description:
348
+ 'Sort by created_at. Under a structured query this is the only ordering (default "asc"). Under `search` (vault#551): omit for FTS5 relevance ranking (default, unchanged) — pass "asc"/"desc" to EXPLICITLY switch to created_at ordering instead of relevance.',
349
+ },
332
350
  limit: { type: "number", description: "Max results (default 50)" },
333
351
  offset: { type: "number", description: "Pagination offset (default 0)" },
334
352
  cursor: {
@@ -502,13 +520,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
502
520
  expand = params.expand as TagExpandMode;
503
521
  }
504
522
 
523
+ // `search_mode` (vault#551) — validate loudly (same policy as
524
+ // `expand` above) so a typo'd value doesn't silently fall back to
525
+ // the default. Resolved here (before the search/structured branch)
526
+ // because BOTH branches need to know whether it was passed: the
527
+ // search branch to select escaping behavior, the structured branch
528
+ // to warn that it's being ignored.
529
+ let searchMode: SearchMode | undefined;
530
+ if (params.search_mode !== undefined && params.search_mode !== null) {
531
+ if (typeof params.search_mode !== "string" || !isValidSearchMode(params.search_mode)) {
532
+ throw new QueryError(
533
+ `invalid \`search_mode\` value ${JSON.stringify(params.search_mode)} — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
534
+ "INVALID_QUERY",
535
+ {
536
+ error_type: "invalid_query",
537
+ field: "search_mode",
538
+ got: params.search_mode,
539
+ hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
540
+ },
541
+ );
542
+ }
543
+ searchMode = params.search_mode;
544
+ }
545
+
505
546
  // --- Full-text search ---
506
547
  let results: Note[];
507
548
  let nextCursor: string | null = null;
508
- // Warnings channel (vault#550) structured-query only for this
509
- // wave; `search=` is out of scope (see #551). Scope-unaware by
510
- // design (see `core/src/query-warnings.ts` doc comment) — a
511
- // tag-scoped MCP session gets these stripped by the
549
+ // Warnings channel (vault#550). `search=` warnings (`empty_search`,
550
+ // `ignored_param` for a stray `search_mode`) joined the channel at
551
+ // #551 — the rest (`unknown_tag`) stays structured-query only.
552
+ // Scope-unaware by design (see `core/src/query-warnings.ts` doc
553
+ // comment) — a tag-scoped MCP session gets these stripped by the
512
554
  // `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
513
555
  // before the result reaches the caller, so an out-of-scope tag name
514
556
  // never leaks via `did_you_mean`.
@@ -516,18 +558,47 @@ Response shape (vault#550 — three variants, pick by what you passed):
516
558
  if (params.search) {
517
559
  // Normalize tag param
518
560
  const tags = normalizeTags(params.tag);
519
- // Route through `store.searchNotes` (not `noteOps.searchNotes`) so
520
- // tag-hierarchy expansion fires for MCP callers the same as for
521
- // HTTP REST callers`tag: "manual"` matches descendants declared
522
- // via `_tags/*` config notes. Mirrors the structured-query fix
523
- // from #214; same class of bypass bug (tracked as #227).
524
- results = await store.searchNotes(params.search as string, {
525
- tags,
526
- limit: (params.limit as number) ?? 50,
527
- expand,
528
- });
561
+ const mode: SearchMode = searchMode ?? "literal";
562
+ // "Only whitespace/quotes" (vault#551 edge case): short-circuit
563
+ // BEFORE ever calling FTS5 an empty/all-punctuation phrase can
564
+ // be a syntax error (or a meaningless always-empty query)
565
+ // depending on exactly how it degenerates, so this is checked
566
+ // here rather than left to the DB layer to (maybe) reject.
567
+ if (mode === "literal" && buildLiteralSearchQuery(params.search as string).isEmpty) {
568
+ results = [];
569
+ queryWarnings.push(emptySearchWarning());
570
+ } else {
571
+ // Route through `store.searchNotes` (not `noteOps.searchNotes`) so
572
+ // tag-hierarchy expansion fires for MCP callers the same as for
573
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
574
+ // via `_tags/*` config notes. Mirrors the structured-query fix
575
+ // from #214; same class of bypass bug (tracked as #227). A
576
+ // malformed advanced-mode query throws here (structured
577
+ // `invalid_search_syntax`, vault#551) — uncaught on purpose, it
578
+ // propagates to `src/mcp-http.ts`, which maps it to a JSON-RPC
579
+ // error the same way it maps `invalid_query`.
580
+ results = await store.searchNotes(params.search as string, {
581
+ tags,
582
+ limit: (params.limit as number) ?? 50,
583
+ expand,
584
+ mode,
585
+ sort: params.sort as "asc" | "desc" | undefined,
586
+ });
587
+ }
529
588
  } else {
530
589
  // --- Structured query ---
590
+ // `search_mode` only shapes how `search` text becomes an FTS5
591
+ // query — passing it without `search` is almost always a mistake
592
+ // (meant to pass `search` too), so flag it rather than silently
593
+ // doing nothing with it.
594
+ if (searchMode !== undefined) {
595
+ queryWarnings.push(
596
+ ignoredParamWarning(
597
+ "search_mode",
598
+ "no `search` was provided — search_mode only affects full-text search query parsing",
599
+ ),
600
+ );
601
+ }
531
602
  const tags = normalizeTags(params.tag);
532
603
  // Accept canonical `exclude_tags` plus camelCase / singular aliases.
533
604
  // LLM callers frequently pick the wrong name (training-data drift
@@ -574,7 +645,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
574
645
  offset: params.offset as number | undefined,
575
646
  cursor: cursorMode ? (params.cursor as string) : undefined,
576
647
  };
577
- queryWarnings = collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy());
648
+ // Concatenate (not overwrite) `queryWarnings` may already carry
649
+ // the `ignored_param` warning for a stray `search_mode` pushed
650
+ // above (vault#551).
651
+ queryWarnings = queryWarnings.concat(
652
+ collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy()),
653
+ );
578
654
  if (cursorMode) {
579
655
  const page = await store.queryNotesPaged(queryOpts);
580
656
  results = page.notes;
package/core/src/notes.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { getIndexedField, releaseField } from "./indexed-fields.js";
23
23
  import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
24
24
  import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
25
+ import { buildLiteralSearchQuery, type SearchMode } from "./search-query.js";
25
26
 
26
27
  let idCounter = 0;
27
28
 
@@ -1309,12 +1310,78 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1309
1310
  return { notes, next_cursor };
1310
1311
  }
1311
1312
 
1313
+ /**
1314
+ * Turn a thrown FTS5 MATCH error into the structured `invalid_search_syntax`
1315
+ * shape (vault#551, WS2A item 2). ALWAYS structured, never a raw rethrow —
1316
+ * a raw `SQLiteError` escaping to the transport is an unstructured 500
1317
+ * (REST) / generic `isError` text (MCP), which is exactly the swallowed-
1318
+ * failure class this wave is closing.
1319
+ *
1320
+ * The two modes carry different hints:
1321
+ * - advanced: the caller passed raw FTS5 syntax that FTS5 rejected — tell
1322
+ * them to fix it or drop back to literal.
1323
+ * - literal: escaping + control-char sanitization
1324
+ * (`buildLiteralSearchQuery`) make every input syntactically valid
1325
+ * FTS5, so this is unreachable by construction — the belt-and-suspenders
1326
+ * structured error (rather than a raw rethrow) means that IF the
1327
+ * invariant is ever broken it still surfaces as an honest error, not a
1328
+ * 500. Signals a vault bug worth reporting.
1329
+ */
1330
+ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
1331
+ const causeMessage = err instanceof Error ? err.message : String(err);
1332
+ const hint =
1333
+ mode === "advanced"
1334
+ ? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
1335
+ : `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`;
1336
+ return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
1337
+ error_type: "invalid_search_syntax",
1338
+ field: "search",
1339
+ got: rawQuery,
1340
+ hint,
1341
+ });
1342
+ }
1343
+
1312
1344
  export function searchNotes(
1313
1345
  db: Database,
1314
1346
  query: string,
1315
- opts?: { tags?: string[]; limit?: number },
1347
+ opts?: { tags?: string[]; limit?: number; mode?: SearchMode; sort?: "asc" | "desc" },
1316
1348
  ): Note[] {
1317
1349
  const limit = typeof opts?.limit === "number" ? opts.limit : 50;
1350
+ // Literal-by-default (vault#551): escape the caller's text so FTS5's own
1351
+ // query syntax (hyphen = NOT, apostrophe/period = tokenizer breakage,
1352
+ // etc.) is treated as ordinary content, not syntax. `search_mode:
1353
+ // "advanced"` opts back into raw FTS5 query syntax — today's pre-#551
1354
+ // behavior, unchanged — for callers who want boolean/phrase/prefix
1355
+ // operators.
1356
+ const mode: SearchMode = opts?.mode ?? "literal";
1357
+ let ftsQuery: string;
1358
+ if (mode === "literal") {
1359
+ const built = buildLiteralSearchQuery(query);
1360
+ // "Only whitespace/quotes" (vault#551 edge case): the caller (store.ts /
1361
+ // the REST + MCP entry points) is expected to short-circuit this case
1362
+ // itself (with an `empty_search` warning) before ever reaching here —
1363
+ // this is a defensive fallback for any direct-core caller that skips
1364
+ // that check.
1365
+ if (built.isEmpty) return [];
1366
+ ftsQuery = built.query;
1367
+ } else {
1368
+ ftsQuery = query;
1369
+ }
1370
+
1371
+ // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
1372
+ // relevance (`rank`, unchanged); an EXPLICIT `sort: "asc"|"desc"` switches
1373
+ // to `created_at` ordering. Checked against the literal string values (not
1374
+ // truthiness) so an absent `sort` can never accidentally match a branch.
1375
+ // `n.id ${direction}` is appended as a deterministic tiebreaker — same
1376
+ // rationale as `queryNotes` (two notes at the same created_at millisecond
1377
+ // would otherwise return in arbitrary/unstable order). `rank` needs no
1378
+ // tiebreaker (bm25 score is effectively unique per row).
1379
+ const orderBy =
1380
+ opts?.sort === "asc"
1381
+ ? "n.created_at ASC, n.id ASC"
1382
+ : opts?.sort === "desc"
1383
+ ? "n.created_at DESC, n.id DESC"
1384
+ : "rank";
1318
1385
 
1319
1386
  if (opts?.tags && opts.tags.length > 0) {
1320
1387
  // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
@@ -1334,12 +1401,19 @@ export function searchNotes(
1334
1401
  JOIN notes_fts fts ON fts.rowid = n.rowid
1335
1402
  WHERE notes_fts MATCH ?
1336
1403
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1337
- ORDER BY rank
1404
+ ORDER BY ${orderBy}
1338
1405
  LIMIT ?
1339
- `).all(query, ...searchTags, limit) as NoteRow[];
1406
+ `).all(ftsQuery, ...searchTags, limit) as NoteRow[];
1340
1407
  return notesWithTags(db, rows);
1341
- } catch {
1342
- return [];
1408
+ } catch (err) {
1409
+ // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1410
+ // advanced mode expects it (the caller passed raw syntax); literal
1411
+ // mode should never reach it (escaping + control-char sanitization
1412
+ // make the query valid) but if the invariant breaks we still want an
1413
+ // honest error, not an unstructured 500. A QueryError we threw
1414
+ // ourselves (empty-limit validation, etc.) is re-raised untouched.
1415
+ if (err instanceof QueryError) throw err;
1416
+ throw searchSyntaxError(query, err, mode);
1343
1417
  }
1344
1418
  }
1345
1419
  }
@@ -1349,12 +1423,13 @@ export function searchNotes(
1349
1423
  SELECT n.* FROM notes n
1350
1424
  JOIN notes_fts fts ON fts.rowid = n.rowid
1351
1425
  WHERE notes_fts MATCH ?
1352
- ORDER BY rank
1426
+ ORDER BY ${orderBy}
1353
1427
  LIMIT ?
1354
- `).all(query, limit) as NoteRow[];
1428
+ `).all(ftsQuery, limit) as NoteRow[];
1355
1429
  return notesWithTags(db, rows);
1356
- } catch {
1357
- return [];
1430
+ } catch (err) {
1431
+ if (err instanceof QueryError) throw err;
1432
+ throw searchSyntaxError(query, err, mode);
1358
1433
  }
1359
1434
  }
1360
1435
 
@@ -182,3 +182,38 @@ export function collectUnknownTagWarnings(
182
182
  }
183
183
  return warnings;
184
184
  }
185
+
186
+ /**
187
+ * `empty_search` warning (vault#551) — the `search` string carried no
188
+ * literal content: blank/whitespace-only, or (in literal mode, where a
189
+ * manually-typed `"` is ordinary content rather than syntax) nothing but
190
+ * quote characters. Rather than let this degenerate into an FTS5 syntax
191
+ * error or a confusing always-empty-but-syntactically-valid query, the
192
+ * search path short-circuits BEFORE ever calling FTS5 and reports this
193
+ * warning alongside an honest `[]`. See `core/src/search-query.ts`
194
+ * `buildLiteralSearchQuery`, which is the choke point that detects this.
195
+ */
196
+ export function emptySearchWarning(): QueryWarning {
197
+ return {
198
+ code: "empty_search",
199
+ message:
200
+ "the search query has no literal content (blank, or only whitespace/quote characters) — returning no results without querying FTS5.",
201
+ };
202
+ }
203
+
204
+ /**
205
+ * `ignored_param` warning (vault#551) — a query-shaping param was passed
206
+ * but has no effect given the rest of the request. First (and so far only)
207
+ * case: `search_mode` without `search` — the mode only shapes how `search`
208
+ * text is turned into an FTS5 query, so passing it alone almost always
209
+ * means the caller meant to pass `search` too. Generic over `param` /
210
+ * `reason` so a future ignored-param case can reuse the same shape instead
211
+ * of inventing a new warning code.
212
+ */
213
+ export function ignoredParamWarning(param: string, reason: string): QueryWarning {
214
+ return {
215
+ code: "ignored_param",
216
+ message: `\`${param}\` has no effect: ${reason}`,
217
+ param,
218
+ };
219
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ SEARCH_MODES,
4
+ escapeFtsToken,
5
+ buildLiteralSearchQuery,
6
+ isValidSearchMode,
7
+ } from "./search-query.js";
8
+
9
+ describe("escapeFtsToken", () => {
10
+ it("wraps a plain token in quotes", () => {
11
+ expect(escapeFtsToken("widgets")).toBe(`"widgets"`);
12
+ });
13
+
14
+ it("doubles an embedded quote", () => {
15
+ expect(escapeFtsToken(`say"hi`)).toBe(`"say""hi"`);
16
+ });
17
+
18
+ it("doubles multiple embedded quotes", () => {
19
+ expect(escapeFtsToken(`"eleven-day`)).toBe(`"""eleven-day"`);
20
+ expect(escapeFtsToken(`delay"`)).toBe(`"delay"""`);
21
+ });
22
+
23
+ it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
24
+ expect(escapeFtsToken("eleven-day")).toBe(`"eleven-day"`);
25
+ expect(escapeFtsToken("18.6")).toBe(`"18.6"`);
26
+ expect(escapeFtsToken("didn't")).toBe(`"didn't"`);
27
+ });
28
+ });
29
+
30
+ describe("buildLiteralSearchQuery", () => {
31
+ it("wraps a single token", () => {
32
+ const r = buildLiteralSearchQuery("widgets");
33
+ expect(r.isEmpty).toBe(false);
34
+ expect(r.query).toBe(`"widgets"`);
35
+ });
36
+
37
+ it("phrase-quotes each whitespace-delimited token and ANDs them (vault#551 root fix)", () => {
38
+ const r = buildLiteralSearchQuery("eleven-day capping delay");
39
+ expect(r.isEmpty).toBe(false);
40
+ expect(r.query).toBe(`"eleven-day" "capping" "delay"`);
41
+ });
42
+
43
+ it("escapes an apostrophe as literal content, not syntax", () => {
44
+ const r = buildLiteralSearchQuery("didn't");
45
+ expect(r.isEmpty).toBe(false);
46
+ expect(r.query).toBe(`"didn't"`);
47
+ });
48
+
49
+ it("escapes a decimal point as literal content, not syntax", () => {
50
+ const r = buildLiteralSearchQuery("18.6");
51
+ expect(r.isEmpty).toBe(false);
52
+ expect(r.query).toBe(`"18.6"`);
53
+ });
54
+
55
+ it("collapses runs of whitespace between tokens", () => {
56
+ const r = buildLiteralSearchQuery("widgets gadgets");
57
+ expect(r.query).toBe(`"widgets" "gadgets"`);
58
+ });
59
+
60
+ it("trims leading/trailing whitespace", () => {
61
+ const r = buildLiteralSearchQuery(" widgets ");
62
+ expect(r.query).toBe(`"widgets"`);
63
+ });
64
+
65
+ it("escapes a manually-quoted phrase's embedded quote characters as content", () => {
66
+ // The user typed literal quote marks expecting phrase syntax; in
67
+ // literal mode those quote characters are just more content that gets
68
+ // escaped like anything else (vault#551 — this is CORRECT, not a bug:
69
+ // raw phrase/boolean/prefix syntax now requires search_mode:"advanced").
70
+ const r = buildLiteralSearchQuery(`"eleven-day capping delay"`);
71
+ expect(r.isEmpty).toBe(false);
72
+ expect(r.query).toBe(`"""eleven-day" "capping" "delay"""`);
73
+ });
74
+
75
+ it("treats a blank string as empty", () => {
76
+ expect(buildLiteralSearchQuery("").isEmpty).toBe(true);
77
+ });
78
+
79
+ it("treats a whitespace-only string as empty", () => {
80
+ expect(buildLiteralSearchQuery(" ").isEmpty).toBe(true);
81
+ });
82
+
83
+ it("treats a lone quote character as empty", () => {
84
+ expect(buildLiteralSearchQuery(`"`).isEmpty).toBe(true);
85
+ });
86
+
87
+ it("treats a quotes-and-whitespace-only string as empty", () => {
88
+ expect(buildLiteralSearchQuery(`" " "`).isEmpty).toBe(true);
89
+ });
90
+
91
+ it("an empty-flagged result never carries a query string callers should use", () => {
92
+ const r = buildLiteralSearchQuery(" ");
93
+ expect(r.isEmpty).toBe(true);
94
+ expect(r.query).toBe("");
95
+ });
96
+
97
+ // Control-character sanitization (vault#551 review fold) — a NUL byte
98
+ // (or any C0/DEL control) inside a token would otherwise crash FTS5's
99
+ // C-string parser with a raw `unterminated string` error, breaking the
100
+ // "literal mode cannot throw" guarantee.
101
+ it("splits a token on an embedded NUL byte — both halves stay searchable", () => {
102
+ const r = buildLiteralSearchQuery("hello\x00world");
103
+ expect(r.isEmpty).toBe(false);
104
+ expect(r.query).toBe(`"hello" "world"`);
105
+ });
106
+
107
+ it("treats a NUL-only string as empty (short-circuits, never reaches FTS5)", () => {
108
+ const r = buildLiteralSearchQuery("\x00");
109
+ expect(r.isEmpty).toBe(true);
110
+ expect(r.query).toBe("");
111
+ });
112
+
113
+ it("collapses a run of NUL/control bytes between tokens like whitespace", () => {
114
+ const r = buildLiteralSearchQuery("hello\x00\x00world");
115
+ expect(r.query).toBe(`"hello" "world"`);
116
+ });
117
+
118
+ it("sanitizes the other C0 controls and DEL (0x01-0x1F, 0x7F) as separators too", () => {
119
+ expect(buildLiteralSearchQuery("a\x01\x02b").query).toBe(`"a" "b"`);
120
+ expect(buildLiteralSearchQuery("a\x1fb").query).toBe(`"a" "b"`);
121
+ expect(buildLiteralSearchQuery("a\x7fb").query).toBe(`"a" "b"`);
122
+ });
123
+
124
+ it("a control byte adjacent to a real token still yields the clean token", () => {
125
+ expect(buildLiteralSearchQuery("\x00hello\x00").query).toBe(`"hello"`);
126
+ });
127
+ });
128
+
129
+ describe("isValidSearchMode / SEARCH_MODES", () => {
130
+ it("SEARCH_MODES is exactly literal + advanced", () => {
131
+ expect(SEARCH_MODES).toEqual(["literal", "advanced"]);
132
+ });
133
+
134
+ it("accepts literal and advanced", () => {
135
+ expect(isValidSearchMode("literal")).toBe(true);
136
+ expect(isValidSearchMode("advanced")).toBe(true);
137
+ });
138
+
139
+ it("rejects an unknown string", () => {
140
+ expect(isValidSearchMode("bogus")).toBe(false);
141
+ });
142
+
143
+ it("rejects non-string values", () => {
144
+ expect(isValidSearchMode(123)).toBe(false);
145
+ expect(isValidSearchMode(null)).toBe(false);
146
+ expect(isValidSearchMode(undefined)).toBe(false);
147
+ expect(isValidSearchMode(["literal"])).toBe(false);
148
+ });
149
+ });
Binary file
package/core/src/store.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  countConformanceViolations,
35
35
  type ConformanceReport,
36
36
  } from "./conformance.js";
37
+ import type { SearchMode } from "./search-query.js";
37
38
 
38
39
  /**
39
40
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -375,7 +376,7 @@ export class BunSqliteStore implements Store {
375
376
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
376
377
  }
377
378
 
378
- async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
379
+ async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]> {
379
380
  // Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
380
381
  // filters before expansion, so `#manual` and `manual` resolve identically.
381
382
  if (opts?.tags && opts.tags.length > 0) {
package/core/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
4
  import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
5
+ import type { SearchMode } from "./search-query.js";
5
6
  import type { ValidationStatus } from "./schema-defaults.js";
6
7
  import type { ConformanceReport } from "./conformance.js";
7
8
  import type { FindPathResult } from "./links.js";
@@ -314,7 +315,18 @@ export interface Store {
314
315
  * agent loop can persist a single watermark and keep polling.
315
316
  */
316
317
  queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
317
- searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]>;
318
+ /**
319
+ * `mode` (vault#551 — literal-by-default): "literal" (default) escapes +
320
+ * phrase-quotes the query so FTS5 punctuation syntax (hyphen = NOT, an
321
+ * apostrophe/period breaking the parse, ...) is treated as ordinary
322
+ * content. "advanced" passes `query` through to FTS5 raw (pre-#551
323
+ * behavior) for callers who want boolean/phrase/prefix operators — a
324
+ * syntax error in that mode throws (`error_type: "invalid_search_syntax"`)
325
+ * rather than silently returning `[]`. `sort` (vault#551): omitted stays
326
+ * FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
327
+ * `created_at` ordering. See `core/src/search-query.ts`.
328
+ */
329
+ searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]>;
318
330
 
319
331
  // Tags
320
332
  tagNote(noteId: string, tags: string[]): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.2",
3
+ "version": "0.7.0-rc.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Contract suite — search (Wave 1 of the Reliability & Usability Program,
3
- * umbrella #556). Encodes the 2026-07-09 nine-persona deep test's search
4
- * findings (WS2, #551) as executable tests: PASSING tests lock in behavior
5
- * that is correct today; `test.todo` entries describe the target behavior
6
- * for confirmed-broken cases, to be flipped to real assertions in a later
7
- * wave. See #551 for the full write-up.
2
+ * Contract suite — search (Wave 1's findings, Wave 3's fix — Reliability &
3
+ * Usability Program, umbrella #556, WS2A #551). Encodes the 2026-07-09
4
+ * nine-persona deep test's search findings as executable tests: PASSING
5
+ * tests lock in behavior that is correct today; the #551 `test.todo`
6
+ * entries this file used to carry have all been flipped to real assertions
7
+ * against the literal-by-default fix (escaping, `search_mode: "advanced"`,
8
+ * honored `sort`, structured `invalid_search_syntax`) — see #551 for the
9
+ * full write-up. Covers `src/routes.ts` (the REST surface) plus a handful
10
+ * of MCP-parity checks (`core/src/mcp.ts`) where the two surfaces share one
11
+ * implementation (`core/src/notes.ts` `searchNotes` + `core/src/search-
12
+ * query.ts`) and a REST-only test wouldn't prove the MCP side actually got
13
+ * the fix — mirrors the pattern in `contract-honest-queries.test.ts`.
8
14
  *
9
15
  * Ground truth for every assertion here was re-verified live against this
10
16
  * repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
@@ -16,6 +22,7 @@ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
16
22
  import { Database } from "bun:sqlite";
17
23
  import { BunStore } from "./vault-store.ts";
18
24
  import { handleNotes } from "./routes.ts";
25
+ import { generateMcpTools } from "../core/src/mcp.ts";
19
26
 
20
27
  let db: Database;
21
28
  let store: BunStore;
@@ -26,6 +33,13 @@ function search(qs: string): Promise<Response> {
26
33
  return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
27
34
  }
28
35
 
36
+ /** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
37
+ function decodeWarningsHeader(res: Response): any[] | null {
38
+ const raw = res.headers.get("X-Parachute-Warnings");
39
+ if (!raw) return null;
40
+ return JSON.parse(decodeURIComponent(raw));
41
+ }
42
+
29
43
  /** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
30
44
  const NOTES = {
31
45
  hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
@@ -76,22 +90,36 @@ describe("contract: search — passing (lock in current behavior)", () => {
76
90
  expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
77
91
  });
78
92
 
79
- it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay"', async () => {
80
- const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&include_content=true`);
93
+ // The next three tests manually quote the search string, expecting raw
94
+ // FTS5 phrase syntax to be honored. That's advanced-mode behavior as of
95
+ // #551 (literal-by-default escapes a caller's own `"` characters as
96
+ // ordinary content instead of honoring them as phrase syntax) — updated
97
+ // to pass `search_mode: "advanced"` explicitly. (Note for reviewers:
98
+ // empirically the bare literal-mode escaping of these SAME manually-
99
+ // quoted strings still resolves to the identical result set for this
100
+ // corpus — FTS5's tokenizer strips the embedded `"` characters the same
101
+ // way it strips other punctuation — but the semantic guarantee these
102
+ // tests were written to pin (phrase ADJACENCY honored as syntax) only
103
+ // holds under advanced mode; literal mode no longer enforces adjacency
104
+ // at all, it just happens to agree here because no other note contains
105
+ // a subset of the same words. See #551 test coverage for the literal-
106
+ // mode equivalents of these same strings, unquoted, further down.)
107
+ it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay" (search_mode: "advanced")', async () => {
108
+ const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&search_mode=advanced&include_content=true`);
81
109
  expect(res.status).toBe(200);
82
110
  const body = await bodyOf(res);
83
111
  expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
84
112
  });
85
113
 
86
- it('quoted decimal literal finds it: "18.6"', async () => {
87
- const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
114
+ it('quoted decimal literal finds it: "18.6" (search_mode: "advanced")', async () => {
115
+ const res = await search(`search=${encodeURIComponent('"18.6"')}&search_mode=advanced&include_content=true`);
88
116
  expect(res.status).toBe(200);
89
117
  const body = await bodyOf(res);
90
118
  expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
91
119
  });
92
120
 
93
- it('quoted contraction finds it: "didn\'t"', async () => {
94
- const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
121
+ it('quoted contraction finds it: "didn\'t" (search_mode: "advanced")', async () => {
122
+ const res = await search(`search=${encodeURIComponent(`"didn't"`)}&search_mode=advanced&include_content=true`);
95
123
  expect(res.status).toBe(200);
96
124
  const body = await bodyOf(res);
97
125
  expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
@@ -105,23 +133,227 @@ describe("contract: search — passing (lock in current behavior)", () => {
105
133
  });
106
134
  });
107
135
 
108
- describe("contract: search — todo (#551, literal-by-default + recall + ranking)", () => {
109
- test.todo(
110
- `#551: unquoted search: "didn't" finds the contraction content (literal-by-default — today the bare apostrophe splits into two AND'd tokens and returns [])`,
111
- );
112
- test.todo(
113
- `#551: unquoted search: "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — today the bare hyphenated word tokenizes into separate AND'd terms and returns [])`,
114
- );
115
- test.todo(
116
- `#551: unquoted search: "18.6" finds the decimal note (literal-by-default — today the bare decimal tokenizes into separate AND'd terms and returns [])`,
117
- );
118
- test.todo(
119
- `#551: search_mode:"advanced" preserves raw FTS5 query syntax (AND/OR/NOT, phrase, prefix) once literal-by-default escaping ships as the new default`,
120
- );
121
- test.todo(
122
- `#551: sort:"asc"/"desc" changes result order under search (today silently ignored — FTS5 rank order wins regardless of the sort param)`,
123
- );
124
- test.todo(
125
- `#551: malformed FTS syntax (e.g. an unbalanced quote) yields a structured warning or error, not a silently swallowed [] (today searchNotes catches every FTS5 syntax error and returns [])`,
126
- );
136
+ describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
137
+ it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
138
+ const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
139
+ expect(res.status).toBe(200);
140
+ const body = await bodyOf(res);
141
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
142
+ });
143
+
144
+ it(`unquoted search "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — the bare hyphenated word used to tokenize into a NOT-operator and return [])`, async () => {
145
+ const res = await search(`search=${encodeURIComponent("eleven-day capping delay")}&include_content=true`);
146
+ expect(res.status).toBe(200);
147
+ const body = await bodyOf(res);
148
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
149
+ });
150
+
151
+ it(`unquoted search "18.6" finds the decimal note (literal-by-default — the bare decimal used to break the FTS5 parse and return [])`, async () => {
152
+ const res = await search(`search=${encodeURIComponent("18.6")}&include_content=true`);
153
+ expect(res.status).toBe(200);
154
+ const body = await bodyOf(res);
155
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
156
+ });
157
+
158
+ it('search_mode:"advanced" preserves raw FTS5 query syntax (boolean AND/NOT, prefix *)', async () => {
159
+ const and = await bodyOf(
160
+ await search(`search=${encodeURIComponent("widgets AND gadgets")}&search_mode=advanced&include_content=true`),
161
+ );
162
+ expect(and.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
163
+
164
+ const not = await bodyOf(
165
+ await search(`search=${encodeURIComponent("widgets NOT gadgets")}&search_mode=advanced&include_content=true`),
166
+ );
167
+ expect(not.map((n: any) => n.content)).toEqual([NOTES.widgetsOnly]);
168
+
169
+ const prefix = await bodyOf(
170
+ await search(`search=${encodeURIComponent("wid*")}&search_mode=advanced&include_content=true`),
171
+ );
172
+ const prefixContents = new Set(prefix.map((n: any) => n.content));
173
+ expect(prefixContents.has(NOTES.widgetsOnly)).toBe(true);
174
+ expect(prefixContents.has(NOTES.bothWords)).toBe(true);
175
+ expect(prefixContents.has(NOTES.gadgetsOnly)).toBe(false);
176
+ });
177
+
178
+ it('sort:"asc"/"desc" changes result order under search (previously silently ignored — FTS5 rank order won regardless of the sort param)', async () => {
179
+ await store.createNote("sortprobe alpha content", { created_at: "2020-01-01T00:00:00.000Z" });
180
+ await store.createNote("sortprobe beta content", { created_at: "2022-06-15T00:00:00.000Z" });
181
+ await store.createNote("sortprobe gamma content", { created_at: "2024-12-31T00:00:00.000Z" });
182
+
183
+ const asc = await bodyOf(await search("search=sortprobe&sort=asc&include_content=true"));
184
+ expect(asc.map((n: any) => n.content)).toEqual([
185
+ "sortprobe alpha content",
186
+ "sortprobe beta content",
187
+ "sortprobe gamma content",
188
+ ]);
189
+
190
+ const desc = await bodyOf(await search("search=sortprobe&sort=desc&include_content=true"));
191
+ expect(desc.map((n: any) => n.content)).toEqual([
192
+ "sortprobe gamma content",
193
+ "sortprobe beta content",
194
+ "sortprobe alpha content",
195
+ ]);
196
+ });
197
+
198
+ it("malformed FTS syntax under search_mode:\"advanced\" yields a structured 400 invalid_search_syntax error, not a silently swallowed []", async () => {
199
+ const res = await search(`search=${encodeURIComponent('"unbalanced')}&search_mode=advanced`);
200
+ expect(res.status).toBe(400);
201
+ const body = await bodyOf(res);
202
+ expect(body.code).toBe("INVALID_QUERY");
203
+ expect(body.error_type).toBe("invalid_search_syntax");
204
+ expect(body.field).toBe("search");
205
+ expect(body.got).toBe('"unbalanced');
206
+ expect(typeof body.hint).toBe("string");
207
+ });
208
+
209
+ it("the SAME malformed string in literal mode (default) returns an honest result, not an error — escaping makes it syntactically valid FTS5", async () => {
210
+ const res = await search(`search=${encodeURIComponent('"unbalanced')}`);
211
+ expect(res.status).toBe(200);
212
+ const body = await bodyOf(res);
213
+ expect(Array.isArray(body)).toBe(true);
214
+ expect(body).toEqual([]);
215
+ });
216
+
217
+ // Review fold: a NUL byte inside a literal-mode token used to crash FTS5's
218
+ // C-string parser (`SQLiteError: unterminated string`) → raw rethrow →
219
+ // unstructured 500. Sanitization (control chars → separators) makes it a
220
+ // normal search. `%00` is the URL-encoding of a NUL byte.
221
+ it("search=hello%00world (NUL byte in a token) does NOT 500 — literal mode cannot throw", async () => {
222
+ await store.createNote("hello world reachable across the NUL split", { tags: ["nul"] });
223
+ const res = await search("search=hello%00world&include_content=true");
224
+ expect(res.status).toBe(200);
225
+ const body = await bodyOf(res);
226
+ expect(Array.isArray(body)).toBe(true);
227
+ // The NUL split the token into "hello" AND "world" — both searchable —
228
+ // so the planted note (containing both words) is found.
229
+ expect(body.some((n: any) => n.content.includes("hello world reachable"))).toBe(true);
230
+ });
231
+
232
+ it("a NUL-only search short-circuits to [] with an empty_search warning, no 500", async () => {
233
+ const res = await search("search=%00");
234
+ expect(res.status).toBe(200);
235
+ const body = await bodyOf(res);
236
+ expect(body).toEqual([]);
237
+ const warnings = decodeWarningsHeader(res);
238
+ expect(warnings).not.toBeNull();
239
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
240
+ });
241
+
242
+ it("sort under search is STABLE at identical created_at (deterministic n.id tiebreaker, both directions)", async () => {
243
+ // Two notes at the SAME millisecond — without the id tiebreaker their
244
+ // relative order would be arbitrary/unstable (review nit).
245
+ const ts = "2023-03-03T03:03:03.030Z";
246
+ await store.createNote("tiebreak content one", { id: "tie-aaa", created_at: ts });
247
+ await store.createNote("tiebreak content two", { id: "tie-bbb", created_at: ts });
248
+
249
+ const asc = await bodyOf(await search("search=tiebreak&sort=asc&include_content=true"));
250
+ const ascIds = asc.map((n: any) => n.id);
251
+ // ASC id tiebreaker → tie-aaa before tie-bbb.
252
+ expect(ascIds).toEqual(["tie-aaa", "tie-bbb"]);
253
+
254
+ const desc = await bodyOf(await search("search=tiebreak&sort=desc&include_content=true"));
255
+ const descIds = desc.map((n: any) => n.id);
256
+ // DESC id tiebreaker → tie-bbb before tie-aaa (the exact reverse).
257
+ expect(descIds).toEqual(["tie-bbb", "tie-aaa"]);
258
+ });
259
+ });
260
+
261
+ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)", () => {
262
+ it("a double space between tokens still finds the note containing both (whitespace runs collapse)", async () => {
263
+ const res = await search(`search=${encodeURIComponent("widgets gadgets")}&include_content=true`);
264
+ expect(res.status).toBe(200);
265
+ const body = await bodyOf(res);
266
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
267
+ });
268
+
269
+ it("whitespace-only search short-circuits to [] with an empty_search warning, no FTS5 call/error", async () => {
270
+ const res = await search(`search=${encodeURIComponent(" ")}`);
271
+ expect(res.status).toBe(200);
272
+ const body = await bodyOf(res);
273
+ expect(body).toEqual([]);
274
+ const warnings = decodeWarningsHeader(res);
275
+ expect(warnings).not.toBeNull();
276
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
277
+ });
278
+
279
+ it('a quotes-only search (e.g. a lone `"`) short-circuits to [] with an empty_search warning', async () => {
280
+ const res = await search(`search=${encodeURIComponent('"')}`);
281
+ expect(res.status).toBe(200);
282
+ const body = await bodyOf(res);
283
+ expect(body).toEqual([]);
284
+ const warnings = decodeWarningsHeader(res);
285
+ expect(warnings).not.toBeNull();
286
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
287
+ });
288
+
289
+ it("tag-filtered search also gets literal escaping (the tag-scoped FTS prepared statement)", async () => {
290
+ await store.createNote(NOTES.hyphenPhrase, { tags: ["probe"] });
291
+ const res = await search(
292
+ `search=${encodeURIComponent("eleven-day capping delay")}&tag=probe&include_content=true`,
293
+ );
294
+ expect(res.status).toBe(200);
295
+ const body = await bodyOf(res);
296
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
297
+ expect(body[0].tags).toContain("probe");
298
+ });
299
+
300
+ it("search_mode without search is a no-op that surfaces an ignored_param warning (structured-query path)", async () => {
301
+ const res = await search("search_mode=advanced");
302
+ expect(res.status).toBe(200);
303
+ const warnings = decodeWarningsHeader(res);
304
+ expect(warnings).not.toBeNull();
305
+ expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
306
+ });
307
+
308
+ it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
309
+ const res = await search("search=widgets&search_mode=bogus");
310
+ expect(res.status).toBe(400);
311
+ const body = await bodyOf(res);
312
+ expect(body.code).toBe("INVALID_QUERY");
313
+ expect(body.error_type).toBe("invalid_query");
314
+ expect(body.field).toBe("search_mode");
315
+ expect(body.got).toBe("bogus");
316
+ });
317
+ });
318
+
319
+ describe("contract: search — MCP parity (#551)", () => {
320
+ it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
321
+ const tools = generateMcpTools(store);
322
+ const query = tools.find((t) => t.name === "query-notes")!;
323
+ const result = (await query.execute({ search: "didn't", include_content: true })) as any[];
324
+ expect(result.map((n: any) => n.content)).toEqual([NOTES.contraction]);
325
+ });
326
+
327
+ it("query-notes: search_mode validation rejects an unknown value", async () => {
328
+ const tools = generateMcpTools(store);
329
+ const query = tools.find((t) => t.name === "query-notes")!;
330
+ await expect(query.execute({ search: "widgets", search_mode: "bogus" })).rejects.toThrow(/search_mode/);
331
+ });
332
+
333
+ it("query-notes: search_mode without search surfaces an ignored_param warning", async () => {
334
+ const tools = generateMcpTools(store);
335
+ const query = tools.find((t) => t.name === "query-notes")!;
336
+ const result = (await query.execute({ search_mode: "advanced" })) as any;
337
+ expect(result.warnings).toBeDefined();
338
+ expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
339
+ });
340
+
341
+ it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
342
+ const tools = generateMcpTools(store);
343
+ const query = tools.find((t) => t.name === "query-notes")!;
344
+ await expect(
345
+ query.execute({ search: '"unbalanced', search_mode: "advanced" }),
346
+ ).rejects.toMatchObject({ error_type: "invalid_search_syntax" });
347
+ });
348
+
349
+ it("query-notes: sort:\"asc\"/\"desc\" changes order under search (parity with REST)", async () => {
350
+ await store.createNote("mcpsortprobe alpha", { created_at: "2020-01-01T00:00:00.000Z" });
351
+ await store.createNote("mcpsortprobe beta", { created_at: "2024-01-01T00:00:00.000Z" });
352
+ const tools = generateMcpTools(store);
353
+ const query = tools.find((t) => t.name === "query-notes")!;
354
+ const asc = (await query.execute({ search: "mcpsortprobe", sort: "asc", include_content: true })) as any[];
355
+ expect(asc.map((n: any) => n.content)).toEqual(["mcpsortprobe alpha", "mcpsortprobe beta"]);
356
+ const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
357
+ expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
358
+ });
127
359
  });
package/src/mcp-http.ts CHANGED
@@ -176,6 +176,19 @@ async function handleMcp(
176
176
  hint: e.hint,
177
177
  });
178
178
  }
179
+ // Advanced-mode full-text search syntax error (vault#551) — a
180
+ // raw-passthrough FTS5 query that FTS5 itself rejected. Same shape as
181
+ // `invalid_query` (field/got/hint) but a DISTINCT `error_type` so a
182
+ // caller can tell "your search_mode:\"advanced\" syntax is malformed"
183
+ // apart from "your query PARAMS are malformed."
184
+ if (e?.error_type === "invalid_search_syntax") {
185
+ throw new McpError(ErrorCode.InvalidParams, message, {
186
+ error_type: "invalid_search_syntax",
187
+ field: e.field,
188
+ got: e.got,
189
+ hint: e.hint,
190
+ });
191
+ }
179
192
  if (e?.code === "CONFLICT") {
180
193
  throw new McpError(ErrorCode.InvalidRequest, message, {
181
194
  error_type: "conflict",
package/src/routes.ts CHANGED
@@ -13,7 +13,8 @@
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
- import { collectUnknownTagWarnings, type QueryWarning } from "../core/src/query-warnings.ts";
16
+ import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
17
+ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
17
18
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
18
19
  import { transactionAsync } from "../core/src/txn.ts";
19
20
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
@@ -660,6 +661,39 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
660
661
  return { expand: expandParam as TagExpandMode };
661
662
  }
662
663
 
664
+ /**
665
+ * Parse + validate `?search_mode=` (vault#551) — how `search` text is
666
+ * turned into an FTS5 query. "literal" (default, omit or pass explicitly):
667
+ * escape + phrase-quote so punctuation is literal content, not FTS5
668
+ * syntax. "advanced": raw FTS5 passthrough (pre-#551 behavior) for
669
+ * boolean/phrase/prefix operators. Same validation shape as
670
+ * `parseExpandParam` — loud 400 on an unknown value rather than a silent
671
+ * fallback to the default.
672
+ *
673
+ * Returns `{ mode }` (undefined when absent/empty → search branch defaults
674
+ * to "literal") or `{ error }` (a 400 Response) on an unknown value.
675
+ */
676
+ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response } {
677
+ const raw = parseQuery(url, "search_mode");
678
+ if (raw === null || raw === "") return {};
679
+ if (!isValidSearchMode(raw)) {
680
+ return {
681
+ error: json(
682
+ {
683
+ error: `invalid \`search_mode\` value "${raw}" — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
684
+ code: "INVALID_QUERY",
685
+ error_type: "invalid_query",
686
+ field: "search_mode",
687
+ got: raw,
688
+ hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
689
+ },
690
+ 400,
691
+ ),
692
+ };
693
+ }
694
+ return { mode: raw };
695
+ }
696
+
663
697
  /**
664
698
  * Parse the shared notes-query parameters (tags / excludeTags / path /
665
699
  * pathPrefix / extension / metadata filters / date filters / sort / paging /
@@ -973,11 +1007,54 @@ async function handleNotesInner(
973
1007
  // value. The validated mode is threaded into the search tag-narrowing.
974
1008
  const tagExpand = parseExpandParam(url);
975
1009
  if (tagExpand.error) return tagExpand.error;
976
- const rawResults = await store.searchNotes(search, {
977
- tags: searchTags,
978
- limit,
979
- expand: tagExpand.expand,
980
- });
1010
+ // `search_mode` (vault#551) same loud-validation policy as
1011
+ // `expand` above; also bypasses `parseNotesQueryOpts` so it needs
1012
+ // its own check here.
1013
+ const searchModeParsed = parseSearchModeParam(url);
1014
+ if (searchModeParsed.error) return searchModeParsed.error;
1015
+ const mode: SearchMode = searchModeParsed.mode ?? "literal";
1016
+ // `sort` under search (vault#551 item 3): omit for FTS5 relevance
1017
+ // (default, unchanged); explicit asc/desc switches to created_at.
1018
+ const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1019
+
1020
+ const searchWarnings: QueryWarning[] = [];
1021
+ let rawResults: Note[];
1022
+ // "Only whitespace/quotes" (vault#551 edge case) — short-circuit
1023
+ // BEFORE ever calling FTS5 rather than risk a syntax error (or a
1024
+ // meaningless always-empty query) on a degenerate escaped phrase.
1025
+ if (mode === "literal" && buildLiteralSearchQuery(search).isEmpty) {
1026
+ rawResults = [];
1027
+ searchWarnings.push(emptySearchWarning());
1028
+ } else {
1029
+ try {
1030
+ // A malformed `search_mode: "advanced"` query throws here
1031
+ // (structured `invalid_search_syntax`, vault#551) instead of
1032
+ // the pre-#551 silent `[]` — caught below and formatted the
1033
+ // same way the structured-query path formats a `QueryError`.
1034
+ rawResults = await store.searchNotes(search, {
1035
+ tags: searchTags,
1036
+ limit,
1037
+ expand: tagExpand.expand,
1038
+ mode,
1039
+ sort,
1040
+ });
1041
+ } catch (e: any) {
1042
+ if (e && e.name === "QueryError") {
1043
+ return json(
1044
+ {
1045
+ error: e.message,
1046
+ code: e.code ?? "INVALID_QUERY",
1047
+ ...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
1048
+ ...(e.field !== undefined ? { field: e.field } : {}),
1049
+ ...(e.got !== undefined ? { got: e.got } : {}),
1050
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1051
+ },
1052
+ 400,
1053
+ );
1054
+ }
1055
+ throw e;
1056
+ }
1057
+ }
981
1058
  // Tag-scope: drop any result the token isn't permitted to see. Filter
982
1059
  // happens after the store query so an empty post-filter list still
983
1060
  // returns 200 [] (consistent with "no matches"), not 403.
@@ -1015,7 +1092,7 @@ async function handleNotesInner(
1015
1092
  );
1016
1093
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1017
1094
  }
1018
- return json(output);
1095
+ return jsonWithWarnings(output, searchWarnings);
1019
1096
  }
1020
1097
 
1021
1098
  // Structured query
@@ -1071,17 +1148,27 @@ async function handleNotesInner(
1071
1148
  400,
1072
1149
  );
1073
1150
  }
1074
- // Warnings channel (vault#550) structured-query only for this wave
1075
- // (search= is out of scope, see #551). Skipped entirely for a
1076
- // tag-scoped session: `collectUnknownTagWarnings` resolves
1077
- // `did_you_mean` against the FULL vault-wide tag catalog, which would
1078
- // leak an out-of-scope tag's existence/name across the scope boundary
1079
- // — the same "no leak" stance this codebase takes everywhere else
1080
- // (docs/contracts/tag-scoped-tokens.md). `removed_param` warnings
1081
- // (below, at the response-shaping stage) carry no tag information and
1082
- // fire regardless of scope.
1151
+ // Warnings channel (vault#550). `unknown_tag` stays structured-query
1152
+ // only (skipped entirely for a tag-scoped session:
1153
+ // `collectUnknownTagWarnings` resolves `did_you_mean` against the
1154
+ // FULL vault-wide tag catalog, which would leak an out-of-scope tag's
1155
+ // existence/name across the scope boundary — the same "no leak"
1156
+ // stance this codebase takes everywhere else
1157
+ // (docs/contracts/tag-scoped-tokens.md)). `removed_param` warnings
1158
+ // carry no tag information and fire regardless of scope. `search_mode`
1159
+ // without `search` (vault#551) also carries no tag information — this
1160
+ // IS the structured-query path precisely because `search` was absent
1161
+ // or empty, so a `search_mode` param here is always a no-op.
1083
1162
  const queryWarnings: QueryWarning[] = [
1084
1163
  ...collectRemovedParamWarnings(url),
1164
+ ...(parseQuery(url, "search_mode")
1165
+ ? [
1166
+ ignoredParamWarning(
1167
+ "search_mode",
1168
+ "no `search` was provided — search_mode only affects full-text search query parsing",
1169
+ ),
1170
+ ]
1171
+ : []),
1085
1172
  ...(tagScope.allowed === null
1086
1173
  ? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
1087
1174
  : []),
@@ -440,6 +440,20 @@ describe("WS live-query — bad query + cap", () => {
440
440
  expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
441
441
  });
442
442
 
443
+ // vault#551 — literal-by-default search + `search_mode` are a
444
+ // snapshot/REST-only concept (escaping happens in `core/src/notes.ts`
445
+ // `searchNotes`, which the live matcher never calls). `search` itself
446
+ // stays categorically unsupported for live subscriptions regardless of
447
+ // mode — confirms `search_mode` doesn't accidentally open a bypass.
448
+ it("rejects an unsupported query with search_mode present too — search_mode doesn't bypass the search rejection", async () => {
449
+ const { server } = makeServer();
450
+ const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi&search_mode=advanced`, {
451
+ headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
452
+ });
453
+ expect(res.status).toBe(400);
454
+ expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
455
+ });
456
+
443
457
  it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
444
458
  const { binding } = makeServer({ maxSubscriptions: 2 });
445
459
  const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({