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

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/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
 
@@ -283,6 +284,11 @@ export class TransitionConflictError extends Error {
283
284
  */
284
285
  export class PathConflictError extends Error {
285
286
  code = "PATH_CONFLICT" as const;
287
+ // Stable error_type (vault#554) — additive; mirrors the `error_type` REST
288
+ // has hardcoded in its json response since #126. Lets the generic MCP
289
+ // domain-error mapping (src/mcp-http.ts) pick this class up without a
290
+ // bespoke branch, the same way REST's catch already does explicitly.
291
+ error_type = "path_conflict" as const;
286
292
  path: string;
287
293
 
288
294
  constructor(path: string) {
@@ -305,6 +311,8 @@ export class PathConflictError extends Error {
305
311
  */
306
312
  export class AmbiguousPathError extends Error {
307
313
  code = "AMBIGUOUS_PATH" as const;
314
+ // Stable error_type (vault#554) — see PathConflictError's comment.
315
+ error_type = "ambiguous_path" as const;
308
316
  path: string;
309
317
  candidates: { id: string; extension: string }[];
310
318
 
@@ -345,6 +353,8 @@ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
345
353
 
346
354
  export class ExtensionValidationError extends Error {
347
355
  code = "INVALID_EXTENSION" as const;
356
+ // Stable error_type (vault#554) — see PathConflictError's comment.
357
+ error_type = "invalid_extension" as const;
348
358
  extension: string;
349
359
  reason: string;
350
360
 
@@ -1309,12 +1319,78 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1309
1319
  return { notes, next_cursor };
1310
1320
  }
1311
1321
 
1322
+ /**
1323
+ * Turn a thrown FTS5 MATCH error into the structured `invalid_search_syntax`
1324
+ * shape (vault#551, WS2A item 2). ALWAYS structured, never a raw rethrow —
1325
+ * a raw `SQLiteError` escaping to the transport is an unstructured 500
1326
+ * (REST) / generic `isError` text (MCP), which is exactly the swallowed-
1327
+ * failure class this wave is closing.
1328
+ *
1329
+ * The two modes carry different hints:
1330
+ * - advanced: the caller passed raw FTS5 syntax that FTS5 rejected — tell
1331
+ * them to fix it or drop back to literal.
1332
+ * - literal: escaping + control-char sanitization
1333
+ * (`buildLiteralSearchQuery`) make every input syntactically valid
1334
+ * FTS5, so this is unreachable by construction — the belt-and-suspenders
1335
+ * structured error (rather than a raw rethrow) means that IF the
1336
+ * invariant is ever broken it still surfaces as an honest error, not a
1337
+ * 500. Signals a vault bug worth reporting.
1338
+ */
1339
+ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
1340
+ const causeMessage = err instanceof Error ? err.message : String(err);
1341
+ const hint =
1342
+ mode === "advanced"
1343
+ ? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
1344
+ : `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.`;
1345
+ return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
1346
+ error_type: "invalid_search_syntax",
1347
+ field: "search",
1348
+ got: rawQuery,
1349
+ hint,
1350
+ });
1351
+ }
1352
+
1312
1353
  export function searchNotes(
1313
1354
  db: Database,
1314
1355
  query: string,
1315
- opts?: { tags?: string[]; limit?: number },
1356
+ opts?: { tags?: string[]; limit?: number; mode?: SearchMode; sort?: "asc" | "desc" },
1316
1357
  ): Note[] {
1317
1358
  const limit = typeof opts?.limit === "number" ? opts.limit : 50;
1359
+ // Literal-by-default (vault#551): escape the caller's text so FTS5's own
1360
+ // query syntax (hyphen = NOT, apostrophe/period = tokenizer breakage,
1361
+ // etc.) is treated as ordinary content, not syntax. `search_mode:
1362
+ // "advanced"` opts back into raw FTS5 query syntax — today's pre-#551
1363
+ // behavior, unchanged — for callers who want boolean/phrase/prefix
1364
+ // operators.
1365
+ const mode: SearchMode = opts?.mode ?? "literal";
1366
+ let ftsQuery: string;
1367
+ if (mode === "literal") {
1368
+ const built = buildLiteralSearchQuery(query);
1369
+ // "Only whitespace/quotes" (vault#551 edge case): the caller (store.ts /
1370
+ // the REST + MCP entry points) is expected to short-circuit this case
1371
+ // itself (with an `empty_search` warning) before ever reaching here —
1372
+ // this is a defensive fallback for any direct-core caller that skips
1373
+ // that check.
1374
+ if (built.isEmpty) return [];
1375
+ ftsQuery = built.query;
1376
+ } else {
1377
+ ftsQuery = query;
1378
+ }
1379
+
1380
+ // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
1381
+ // relevance (`rank`, unchanged); an EXPLICIT `sort: "asc"|"desc"` switches
1382
+ // to `created_at` ordering. Checked against the literal string values (not
1383
+ // truthiness) so an absent `sort` can never accidentally match a branch.
1384
+ // `n.id ${direction}` is appended as a deterministic tiebreaker — same
1385
+ // rationale as `queryNotes` (two notes at the same created_at millisecond
1386
+ // would otherwise return in arbitrary/unstable order). `rank` needs no
1387
+ // tiebreaker (bm25 score is effectively unique per row).
1388
+ const orderBy =
1389
+ opts?.sort === "asc"
1390
+ ? "n.created_at ASC, n.id ASC"
1391
+ : opts?.sort === "desc"
1392
+ ? "n.created_at DESC, n.id DESC"
1393
+ : "rank";
1318
1394
 
1319
1395
  if (opts?.tags && opts.tags.length > 0) {
1320
1396
  // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
@@ -1334,12 +1410,19 @@ export function searchNotes(
1334
1410
  JOIN notes_fts fts ON fts.rowid = n.rowid
1335
1411
  WHERE notes_fts MATCH ?
1336
1412
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1337
- ORDER BY rank
1413
+ ORDER BY ${orderBy}
1338
1414
  LIMIT ?
1339
- `).all(query, ...searchTags, limit) as NoteRow[];
1415
+ `).all(ftsQuery, ...searchTags, limit) as NoteRow[];
1340
1416
  return notesWithTags(db, rows);
1341
- } catch {
1342
- return [];
1417
+ } catch (err) {
1418
+ // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1419
+ // advanced mode expects it (the caller passed raw syntax); literal
1420
+ // mode should never reach it (escaping + control-char sanitization
1421
+ // make the query valid) but if the invariant breaks we still want an
1422
+ // honest error, not an unstructured 500. A QueryError we threw
1423
+ // ourselves (empty-limit validation, etc.) is re-raised untouched.
1424
+ if (err instanceof QueryError) throw err;
1425
+ throw searchSyntaxError(query, err, mode);
1343
1426
  }
1344
1427
  }
1345
1428
  }
@@ -1349,12 +1432,13 @@ export function searchNotes(
1349
1432
  SELECT n.* FROM notes n
1350
1433
  JOIN notes_fts fts ON fts.rowid = n.rowid
1351
1434
  WHERE notes_fts MATCH ?
1352
- ORDER BY rank
1435
+ ORDER BY ${orderBy}
1353
1436
  LIMIT ?
1354
- `).all(query, limit) as NoteRow[];
1437
+ `).all(ftsQuery, limit) as NoteRow[];
1355
1438
  return notesWithTags(db, rows);
1356
- } catch {
1357
- return [];
1439
+ } catch (err) {
1440
+ if (err instanceof QueryError) throw err;
1441
+ throw searchSyntaxError(query, err, mode);
1358
1442
  }
1359
1443
  }
1360
1444
 
@@ -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) {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { Database } from "bun:sqlite";
19
+ import { mapFieldType, validateFieldName } from "./indexed-fields.js";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Types
@@ -280,17 +281,23 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
280
281
  * time"); dropping the inner-shape gate is consistent with that intent.
281
282
  */
282
283
  export function validateRelationships(raw: unknown): Record<string, unknown> {
284
+ // `error_type` (vault#554) is attached directly on the thrown Error
285
+ // (duck-typed extra property, same pattern as QueryError's optional
286
+ // fields) rather than via a dedicated class — this is a validation
287
+ // leaf with one caller-facing shape, not a reusable domain error. Both
288
+ // REST (src/routes.ts, which already hardcodes this same string) and the
289
+ // generic MCP domain-error mapping (src/mcp-http.ts) key off it.
283
290
  if (raw === null || raw === undefined) {
284
- throw new Error("relationships: expected an object, got null/undefined");
291
+ throw relationshipsError("relationships: expected an object, got null/undefined");
285
292
  }
286
293
  if (typeof raw !== "object" || Array.isArray(raw)) {
287
- throw new Error(
294
+ throw relationshipsError(
288
295
  "relationships: expected an object mapping relationship name → value (got an array or primitive)",
289
296
  );
290
297
  }
291
298
  for (const rel of Object.keys(raw as Record<string, unknown>)) {
292
299
  if (!rel) {
293
- throw new Error("relationships: keys must be non-empty strings");
300
+ throw relationshipsError("relationships: keys must be non-empty strings");
294
301
  }
295
302
  }
296
303
  // Round-trip through JSON to (a) confirm the payload is serializable —
@@ -300,11 +307,15 @@ export function validateRelationships(raw: unknown): Record<string, unknown> {
300
307
  try {
301
308
  serialized = JSON.stringify(raw);
302
309
  } catch (err) {
303
- throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
310
+ throw relationshipsError(`relationships: value must be JSON-serializable (${(err as Error).message})`);
304
311
  }
305
312
  return JSON.parse(serialized) as Record<string, unknown>;
306
313
  }
307
314
 
315
+ function relationshipsError(message: string): Error {
316
+ return Object.assign(new Error(message), { error_type: "invalid_relationships" as const });
317
+ }
318
+
308
319
  // ---------------------------------------------------------------------------
309
320
  // Helpers
310
321
  // ---------------------------------------------------------------------------
@@ -334,3 +345,174 @@ function jsonOrNull(value: unknown): string | null {
334
345
  }
335
346
  return JSON.stringify(value);
336
347
  }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Cross-tag field validation (vault#553 / #554) — update-tag messaging
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * A single field-declaration violation surfaced by {@link collectTagFieldViolations}.
355
+ * `reason` is a stable machine-checkable slug; `message` is the existing
356
+ * human-readable prose (unchanged wording, just no longer thrown on the
357
+ * first hit).
358
+ */
359
+ export interface TagFieldViolation {
360
+ field: string;
361
+ reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
362
+ message: string;
363
+ /**
364
+ * The conflicting declarer tag — present on the cross-tag reasons
365
+ * (`type_conflict` / `indexed_flag_conflict`) only; the solo own-field
366
+ * reasons have no other party. Core populates it unconditionally
367
+ * (scope-unaware by architecture); the SERVER layers scrub it — and
368
+ * generalize `message` — when the declarer is outside a tag-scoped
369
+ * caller's allowlist (`scrubTagFieldViolationsByScope` in
370
+ * src/tag-scope.ts), so a scoped session learns THAT a conflict exists
371
+ * (the write is still rejected — integrity is scope-independent) but not
372
+ * WHICH out-of-scope tag it conflicts with or what that tag declares.
373
+ */
374
+ other_tag?: string;
375
+ }
376
+
377
+ /**
378
+ * Thrown by `update-tag` (MCP tool + REST `PUT /api/tags/:name`) when one or
379
+ * more fields in the incoming `fields` payload conflict with another tag's
380
+ * declaration, or declare an unindexable/invalid indexed field. Carries
381
+ * EVERY violation found in the call (vault#553 settled complaint: the prior
382
+ * behavior threw on the FIRST offending field and silently never reported
383
+ * the rest — two testers independently assumed the other fields had landed).
384
+ * The message states explicitly that no changes were applied — the whole
385
+ * `upsertTagRecord` call is validated BEFORE any write, so a rejection here
386
+ * always leaves the tag record untouched (atomicity unchanged from before;
387
+ * this only improves what the caller is told).
388
+ */
389
+ export class TagFieldConflictError extends Error {
390
+ code = "TAG_FIELD_CONFLICT" as const;
391
+ error_type = "tag_field_conflict" as const;
392
+ tag: string;
393
+ violations: TagFieldViolation[];
394
+
395
+ constructor(tag: string, violations: TagFieldViolation[]) {
396
+ const summary = violations.map((v) => `${v.field} (${v.reason}): ${v.message}`).join("; ");
397
+ super(
398
+ `tag_field_conflict: ${violations.length} field violation(s) for tag "${tag}" — no changes were applied. ${summary}`,
399
+ );
400
+ this.name = "TagFieldConflictError";
401
+ this.tag = tag;
402
+ this.violations = violations;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Validate the fields a caller is declaring/redeclaring for `tag` against
408
+ * every OTHER tag's schema — `type` and `indexed` must agree across all
409
+ * declarers (both are global per field). Returns every violation found —
410
+ * never throws — so the caller can report the complete set in one
411
+ * structured error (or none, when the array is empty).
412
+ *
413
+ * `incomingFields` is the set of fields THIS call is declaring (MCP:
414
+ * `params.fields`; REST: `body.fields`) — not the full merged field map.
415
+ * Fields the call doesn't touch were already valid (or already accepted)
416
+ * and are not re-validated here, matching the pre-#553 scope of the check.
417
+ *
418
+ * Two deliberate exclusions preserve pre-existing wire contracts
419
+ * (vault#554 — statuses/error_types on previously-working calls must not
420
+ * change):
421
+ *
422
+ * 1. The SEPARATE own-field checks (unsupported type for indexing,
423
+ * invalid identifier) are NOT included — REST's `PUT /api/tags/:name`
424
+ * has an established, tested single-violation `IndexedFieldError` →
425
+ * 400 `invalid_indexed_field` contract for those (vault#478). Use
426
+ * {@link collectTagFieldViolations} (own-field checks included) for a
427
+ * caller — MCP's `update-tag` tool — with no such prior contract.
428
+ * 2. The type-conflict check is SKIPPED for any field whose INCOMING spec
429
+ * is `indexed: true` (wire review, vault#554): a both-indexed cross-tag
430
+ * type conflict already errors on main via `store.upsertTagRecord` →
431
+ * `declareField`'s cross-declarer sqlite-type check (`IndexedFieldError`
432
+ * → 400 `invalid_indexed_field`), and reporting it here would flip that
433
+ * established 400 to a 422. Letting it fall through preserves the 400.
434
+ * No case is silently re-accepted by the skip: an incoming-indexed type
435
+ * conflict against an INDEXED declarer hits declareField's 400; against
436
+ * a NON-indexed declarer the indexed-FLAG conflict below still fires
437
+ * (the flags necessarily differ). What this function newly reports —
438
+ * both silent 200s on main — is (a) type conflicts on NON-indexed
439
+ * incoming fields and (b) indexed-flag conflicts.
440
+ */
441
+ export function collectCrossTagFieldViolations(
442
+ db: Database,
443
+ tag: string,
444
+ incomingFields: Record<string, TagFieldSchema>,
445
+ ): TagFieldViolation[] {
446
+ const violations: TagFieldViolation[] = [];
447
+ const otherSchemas = listTagSchemas(db).filter((s) => s.tag !== tag);
448
+
449
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
450
+ const incomingIndexed = spec.indexed === true;
451
+ for (const other of otherSchemas) {
452
+ const otherSpec = other.fields?.[fieldName];
453
+ if (!otherSpec) continue;
454
+ // See doc comment exclusion 2: incoming-indexed type conflicts keep
455
+ // their pre-existing declareField → 400 invalid_indexed_field path.
456
+ if (!incomingIndexed && otherSpec.type !== spec.type) {
457
+ violations.push({
458
+ field: fieldName,
459
+ reason: "type_conflict",
460
+ message: `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
461
+ other_tag: other.tag,
462
+ });
463
+ }
464
+ if ((otherSpec.indexed === true) !== incomingIndexed) {
465
+ violations.push({
466
+ field: fieldName,
467
+ reason: "indexed_flag_conflict",
468
+ message: `field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
469
+ other_tag: other.tag,
470
+ });
471
+ }
472
+ }
473
+ }
474
+ return violations;
475
+ }
476
+
477
+ /**
478
+ * Full field-violation collection: {@link collectCrossTagFieldViolations}
479
+ * PLUS the own-field checks (unsupported type for indexing, invalid
480
+ * identifier). Used by the MCP `update-tag` tool, which — unlike REST — had
481
+ * no prior single-violation status-code contract to preserve for those two
482
+ * checks (its old inline loop threw an unstructured `Error` for them, same
483
+ * as everything else pre-#554); collecting everything here is a strict
484
+ * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
485
+ * why REST calls that narrower function directly instead of this one.
486
+ */
487
+ export function collectTagFieldViolations(
488
+ db: Database,
489
+ tag: string,
490
+ incomingFields: Record<string, TagFieldSchema>,
491
+ ): TagFieldViolation[] {
492
+ const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
493
+
494
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
495
+ if (spec.indexed === true) {
496
+ const mapped = mapFieldType(spec.type);
497
+ if (!mapped) {
498
+ violations.push({
499
+ field: fieldName,
500
+ reason: "unsupported_indexed_type",
501
+ message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
502
+ });
503
+ } else {
504
+ try {
505
+ validateFieldName(fieldName);
506
+ } catch (err) {
507
+ violations.push({
508
+ field: fieldName,
509
+ reason: "invalid_field_name",
510
+ message: (err as Error).message,
511
+ });
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ return violations;
518
+ }
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.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",