@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.
@@ -22,6 +22,7 @@ import { Database } from "bun:sqlite";
22
22
  import { SqliteStore } from "./store.js";
23
23
  import { generateMcpTools } from "./mcp.js";
24
24
  import { TransitionConflictError } from "./notes.js";
25
+ import { TagFieldConflictError } from "./tag-schemas.js";
25
26
 
26
27
  let store: SqliteStore;
27
28
  let db: Database;
@@ -97,10 +98,98 @@ describe("contract: typed indexes — todo (#553)", () => {
97
98
  test.todo(
98
99
  `#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
99
100
  );
100
- test.todo(
101
- `#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
102
- );
103
101
  test.todo(
104
102
  `#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
105
103
  );
106
104
  });
105
+
106
+ describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {
107
+ it("update-tag reports ALL invalid fields in one call (not just the first) and states explicitly that no changes were applied", async () => {
108
+ // Tag "a" declares two fields. Tag "b" then redeclares BOTH with
109
+ // conflicting specs — a NON-indexed type conflict on "x" AND an
110
+ // indexed-flag conflict on "y" — in the SAME update-tag call. Pre-#553
111
+ // the cross-tag validation loop threw on the first offending field
112
+ // ("x") and never even evaluated "y"; two testers independently assumed
113
+ // the whole call (including "y") had partially landed. (The
114
+ // BOTH-indexed type-conflict case is deliberately absent here — it
115
+ // keeps its pre-existing declareField → IndexedFieldError path; see
116
+ // `collectCrossTagFieldViolations`'s doc-comment exclusion 2 and the
117
+ // both-indexed test below.)
118
+ await store.upsertTagRecord("a", {
119
+ fields: {
120
+ x: { type: "string" },
121
+ y: { type: "boolean", indexed: true },
122
+ },
123
+ });
124
+
125
+ const tools = generateMcpTools(store);
126
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
127
+
128
+ let caught: unknown;
129
+ try {
130
+ await updateTag.execute({
131
+ tag: "b",
132
+ fields: {
133
+ x: { type: "integer" }, // non-indexed type_conflict vs tag "a"
134
+ y: { type: "boolean", indexed: false }, // indexed_flag_conflict vs tag "a"
135
+ },
136
+ });
137
+ } catch (e) {
138
+ caught = e;
139
+ }
140
+
141
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
142
+ const err = caught as TagFieldConflictError;
143
+ expect(err.violations).toHaveLength(2);
144
+ const byField = new Map(err.violations.map((v) => [v.field, v.reason]));
145
+ expect(byField.get("x")).toBe("type_conflict");
146
+ expect(byField.get("y")).toBe("indexed_flag_conflict");
147
+ // States explicitly that no changes were applied.
148
+ expect(err.message).toContain("no changes were applied");
149
+ // Structured conflicting-declarer field (scrubbed by the server layer
150
+ // for tag-scoped sessions; full detail here — core is scope-unaware).
151
+ expect(err.violations[0]!.other_tag).toBe("a");
152
+
153
+ // Nothing partially landed — tag "b" has no field declarations at all.
154
+ const bRecord = await store.getTagRecord("b");
155
+ expect(bRecord?.fields ?? null).toBeFalsy();
156
+ });
157
+
158
+ it("a BOTH-indexed cross-tag type conflict keeps its pre-existing IndexedFieldError path (not TagFieldConflictError)", async () => {
159
+ // Wire-contract floor (vault#554): this exact case already errored on
160
+ // main via store.upsertTagRecord → declareField's cross-declarer
161
+ // sqlite-type check → IndexedFieldError (REST maps it to 400
162
+ // invalid_indexed_field). The new pre-check must not intercept it.
163
+ await store.upsertTagRecord("a", {
164
+ fields: { x: { type: "string", indexed: true } },
165
+ });
166
+
167
+ const tools = generateMcpTools(store);
168
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
169
+
170
+ let caught: any;
171
+ try {
172
+ await updateTag.execute({
173
+ tag: "b",
174
+ fields: { x: { type: "integer", indexed: true } },
175
+ });
176
+ } catch (e) {
177
+ caught = e;
178
+ }
179
+
180
+ expect(caught).toBeDefined();
181
+ expect(caught).not.toBeInstanceOf(TagFieldConflictError);
182
+ expect(caught.name).toBe("IndexedFieldError");
183
+ expect(caught.error_type).toBe("invalid_indexed_field");
184
+ // The structured declarer context the server's tag-scope scrub keys on.
185
+ expect(caught.field).toBe("x");
186
+ expect(caught.declarer_tags).toEqual(["a"]);
187
+
188
+ // The store's transaction rolled back — nothing persisted for "b".
189
+ const bRecord = await store.getTagRecord("b");
190
+ expect(bRecord?.fields ?? null).toBeFalsy();
191
+ });
192
+ // REST `PUT /api/tags/:name` coverage for the same behavior lives in
193
+ // src/contract-errors.test.ts (core/ shouldn't import from src/ — that
194
+ // boundary runs one direction only).
195
+ });
@@ -14,6 +14,7 @@ import {
14
14
  validateFieldName,
15
15
  } from "./indexed-fields.js";
16
16
  import { buildVaultProjection } from "./vault-projection.js";
17
+ import { TagFieldConflictError } from "./tag-schemas.js";
17
18
 
18
19
  let db: Database;
19
20
  let store: SqliteStore;
@@ -186,9 +187,17 @@ describe("update-tag: indexed flag", () => {
186
187
  it("type conflict across declarers throws and names the other tag", async () => {
187
188
  const t = findTool("update-tag");
188
189
  await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
190
+ // vault#554 wire review: a BOTH-indexed cross-tag type conflict is
191
+ // deliberately excluded from the update-tag pre-check (see
192
+ // `collectCrossTagFieldViolations` doc-comment exclusion 2) so it keeps
193
+ // its pre-existing declareField → IndexedFieldError path — REST maps
194
+ // that to the established 400 invalid_indexed_field. The declarer is
195
+ // still named (declareField's message shape), which is this test's
196
+ // intent; the pre-#554 inline-loop wording (`tag "project" declares
197
+ // "string"`) is gone along with the loop.
189
198
  expect(() =>
190
199
  t.execute({ tag: "ticket", fields: { status: { type: "integer", indexed: true } } }),
191
- ).toThrow(/tag "project".*"string"/);
200
+ ).toThrow(/declared by tag\(s\) \[project\]/);
192
201
  });
193
202
 
194
203
  it("indexed-flag conflict across declarers throws", async () => {
@@ -209,12 +218,28 @@ describe("update-tag: indexed flag", () => {
209
218
  });
210
219
 
211
220
  it("invalid field name for indexing throws", async () => {
212
- expect(() =>
213
- findTool("update-tag").execute({
221
+ // vault#553/#554: update-tag's cross-tag field validation now collects
222
+ // EVERY violation into one `TagFieldConflictError` (carrying a
223
+ // `violations` array) instead of throwing the first `IndexedFieldError`
224
+ // it hits — see core/src/tag-schemas.ts `collectTagFieldViolations`.
225
+ // The underlying reason ("invalid_field_name") and message text are
226
+ // unchanged; only the thrown class + the "collect everything" framing
227
+ // are new.
228
+ let caught: unknown;
229
+ try {
230
+ await findTool("update-tag").execute({
214
231
  tag: "project",
215
232
  fields: { "bad-name": { type: "string", indexed: true } },
216
- }),
217
- ).toThrow(IndexedFieldError);
233
+ });
234
+ } catch (e) {
235
+ caught = e;
236
+ }
237
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
238
+ const err = caught as TagFieldConflictError;
239
+ expect(err.violations).toHaveLength(1);
240
+ expect(err.violations[0]!.field).toBe("bad-name");
241
+ expect(err.violations[0]!.reason).toBe("invalid_field_name");
242
+ expect(err.message).toContain("no changes were applied");
218
243
  });
219
244
 
220
245
  it("rejects non-atomic indexed-flag change while other declarers hold it true", async () => {
@@ -45,6 +45,22 @@ interface IndexedFieldRow {
45
45
 
46
46
  export class IndexedFieldError extends Error {
47
47
  override name = "IndexedFieldError";
48
+ // Stable error_type (vault#554) — additive; matches the string REST has
49
+ // hardcoded in its json response since vault#478. Lets the generic MCP
50
+ // domain-error mapping (src/mcp-http.ts) pick this class up.
51
+ error_type = "invalid_indexed_field" as const;
52
+ /**
53
+ * Structured context for the CROSS-DECLARER sqlite-type conflict thrown
54
+ * by `declareField` (vault#554 auth-and-scope fold): the message names
55
+ * the other declarer tag(s), which a tag-scoped caller must not learn
56
+ * when they're outside its allowlist. `declarer_tags` lets the server
57
+ * layer (`scrubIndexedFieldConflictError` in src/tag-scope.ts) detect
58
+ * that case and generalize the message. Absent on the solo own-field
59
+ * throws (invalid name, unsupported type), whose messages name no other
60
+ * tag.
61
+ */
62
+ field?: string;
63
+ declarer_tags?: string[];
48
64
  }
49
65
 
50
66
  // Restrict field names to safe SQL identifiers. This also bounds the
@@ -172,8 +188,14 @@ export function declareField(
172
188
  if (existing.sqliteType !== sqliteType) {
173
189
  const others = existing.declarerTags.filter((t) => t !== tag);
174
190
  if (others.length > 0) {
175
- throw new IndexedFieldError(
176
- `field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
191
+ // `field` + `declarer_tags` stamped so the server's tag-scope layer
192
+ // can generalize this message when a declarer is outside a scoped
193
+ // caller's allowlist (see IndexedFieldError's doc comment).
194
+ throw Object.assign(
195
+ new IndexedFieldError(
196
+ `field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
197
+ ),
198
+ { field, declarer_tags: others },
177
199
  );
178
200
  }
179
201
  dropColumnAndIndex(db, field);
package/core/src/mcp.ts CHANGED
@@ -5,11 +5,11 @@ 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";
12
- import * as indexedFieldOps from "./indexed-fields.js";
13
13
  import {
14
14
  SchemaValidationError,
15
15
  strictViolations,
@@ -52,6 +52,22 @@ export interface McpToolDef {
52
52
  // Helpers
53
53
  // ---------------------------------------------------------------------------
54
54
 
55
+ /**
56
+ * Build a plain `Error` carrying a stable `error_type` (+ optional `field`/
57
+ * `hint`) as duck-typed extra properties — the same pattern `QueryError`
58
+ * uses for its optional structured fields. For validation leaves that don't
59
+ * warrant a dedicated exported class (one caller-facing shape, not a reusable
60
+ * domain error), this is what lets the generic domain-error mapping in
61
+ * `src/mcp-http.ts` surface `data.error_type` instead of falling through to
62
+ * the unstructured `isError: true` text fallback (vault#554).
63
+ */
64
+ function structuredError(
65
+ message: string,
66
+ fields: { error_type: string; field?: string; hint?: string },
67
+ ): Error {
68
+ return Object.assign(new Error(message), fields);
69
+ }
70
+
55
71
  /**
56
72
  * Resolve a note identifier — tries ID first, then case-insensitive
57
73
  * path match. Works everywhere a note reference is accepted.
@@ -85,7 +101,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
85
101
 
86
102
  function requireNote(db: Database, idOrPath: string): Note {
87
103
  const note = resolveNote(db, idOrPath);
88
- if (!note) throw new Error(`Note not found: "${idOrPath}"`);
104
+ if (!note) {
105
+ throw structuredError(`Note not found: "${idOrPath}"`, { error_type: "not_found", field: "id" });
106
+ }
89
107
  return note;
90
108
  }
91
109
 
@@ -243,7 +261,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
243
261
  Response shape (vault#550 — three variants, pick by what you passed):
244
262
  - Default (no \`cursor\`, no warnings): a bare array of notes.
245
263
  - 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.`,
264
+ - 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.
265
+
266
+ \`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
267
  inputSchema: {
248
268
  type: "object",
249
269
  properties: {
@@ -297,7 +317,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
297
317
  ],
298
318
  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
319
  },
300
- search: { type: "string", description: "Full-text search query" },
320
+ search: {
321
+ type: "string",
322
+ description:
323
+ '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.',
324
+ },
325
+ search_mode: {
326
+ type: "string",
327
+ enum: [...SEARCH_MODES],
328
+ description:
329
+ '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").',
330
+ },
301
331
  metadata: {
302
332
  type: "object",
303
333
  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 +358,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
328
358
  required: ["note_id"],
329
359
  description: "Scope results to notes within N hops of an anchor note",
330
360
  },
331
- sort: { type: "string", enum: ["asc", "desc"], description: "Sort by created_at" },
361
+ sort: {
362
+ type: "string",
363
+ enum: ["asc", "desc"],
364
+ description:
365
+ '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.',
366
+ },
332
367
  limit: { type: "number", description: "Max results (default 50)" },
333
368
  offset: { type: "number", description: "Pagination offset (default 0)" },
334
369
  cursor: {
@@ -405,7 +440,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
405
440
  // --- Single note by ID/path ---
406
441
  if (params.id) {
407
442
  const note = resolveNote(db, params.id as string);
408
- if (!note) return { error: "Note not found", id: params.id };
443
+ if (!note) return { error: "Note not found", error_type: "not_found", id: params.id };
409
444
  const includeContent = params.include_content !== false; // default true for single
410
445
  // Range params are meaningless on a content-less shape — error
411
446
  // rather than silently ignore (same loud-validation policy as
@@ -453,7 +488,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
453
488
  if (params.near) {
454
489
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
455
490
  const anchor = resolveNote(db, near.note_id);
456
- if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
491
+ if (!anchor) return { error: "Anchor note not found", error_type: "not_found", note_id: near.note_id };
457
492
  const depth = Math.min(near.depth ?? 2, 5);
458
493
  const traversed = linkOps.traverseLinks(db, anchor.id, {
459
494
  max_depth: depth,
@@ -502,13 +537,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
502
537
  expand = params.expand as TagExpandMode;
503
538
  }
504
539
 
540
+ // `search_mode` (vault#551) — validate loudly (same policy as
541
+ // `expand` above) so a typo'd value doesn't silently fall back to
542
+ // the default. Resolved here (before the search/structured branch)
543
+ // because BOTH branches need to know whether it was passed: the
544
+ // search branch to select escaping behavior, the structured branch
545
+ // to warn that it's being ignored.
546
+ let searchMode: SearchMode | undefined;
547
+ if (params.search_mode !== undefined && params.search_mode !== null) {
548
+ if (typeof params.search_mode !== "string" || !isValidSearchMode(params.search_mode)) {
549
+ throw new QueryError(
550
+ `invalid \`search_mode\` value ${JSON.stringify(params.search_mode)} — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
551
+ "INVALID_QUERY",
552
+ {
553
+ error_type: "invalid_query",
554
+ field: "search_mode",
555
+ got: params.search_mode,
556
+ hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
557
+ },
558
+ );
559
+ }
560
+ searchMode = params.search_mode;
561
+ }
562
+
505
563
  // --- Full-text search ---
506
564
  let results: Note[];
507
565
  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
566
+ // Warnings channel (vault#550). `search=` warnings (`empty_search`,
567
+ // `ignored_param` for a stray `search_mode`) joined the channel at
568
+ // #551 — the rest (`unknown_tag`) stays structured-query only.
569
+ // Scope-unaware by design (see `core/src/query-warnings.ts` doc
570
+ // comment) — a tag-scoped MCP session gets these stripped by the
512
571
  // `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
513
572
  // before the result reaches the caller, so an out-of-scope tag name
514
573
  // never leaks via `did_you_mean`.
@@ -516,18 +575,47 @@ Response shape (vault#550 — three variants, pick by what you passed):
516
575
  if (params.search) {
517
576
  // Normalize tag param
518
577
  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
- });
578
+ const mode: SearchMode = searchMode ?? "literal";
579
+ // "Only whitespace/quotes" (vault#551 edge case): short-circuit
580
+ // BEFORE ever calling FTS5 an empty/all-punctuation phrase can
581
+ // be a syntax error (or a meaningless always-empty query)
582
+ // depending on exactly how it degenerates, so this is checked
583
+ // here rather than left to the DB layer to (maybe) reject.
584
+ if (mode === "literal" && buildLiteralSearchQuery(params.search as string).isEmpty) {
585
+ results = [];
586
+ queryWarnings.push(emptySearchWarning());
587
+ } else {
588
+ // Route through `store.searchNotes` (not `noteOps.searchNotes`) so
589
+ // tag-hierarchy expansion fires for MCP callers the same as for
590
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
591
+ // via `_tags/*` config notes. Mirrors the structured-query fix
592
+ // from #214; same class of bypass bug (tracked as #227). A
593
+ // malformed advanced-mode query throws here (structured
594
+ // `invalid_search_syntax`, vault#551) — uncaught on purpose, it
595
+ // propagates to `src/mcp-http.ts`, which maps it to a JSON-RPC
596
+ // error the same way it maps `invalid_query`.
597
+ results = await store.searchNotes(params.search as string, {
598
+ tags,
599
+ limit: (params.limit as number) ?? 50,
600
+ expand,
601
+ mode,
602
+ sort: params.sort as "asc" | "desc" | undefined,
603
+ });
604
+ }
529
605
  } else {
530
606
  // --- Structured query ---
607
+ // `search_mode` only shapes how `search` text becomes an FTS5
608
+ // query — passing it without `search` is almost always a mistake
609
+ // (meant to pass `search` too), so flag it rather than silently
610
+ // doing nothing with it.
611
+ if (searchMode !== undefined) {
612
+ queryWarnings.push(
613
+ ignoredParamWarning(
614
+ "search_mode",
615
+ "no `search` was provided — search_mode only affects full-text search query parsing",
616
+ ),
617
+ );
618
+ }
531
619
  const tags = normalizeTags(params.tag);
532
620
  // Accept canonical `exclude_tags` plus camelCase / singular aliases.
533
621
  // LLM callers frequently pick the wrong name (training-data drift
@@ -574,7 +662,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
574
662
  offset: params.offset as number | undefined,
575
663
  cursor: cursorMode ? (params.cursor as string) : undefined,
576
664
  };
577
- queryWarnings = collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy());
665
+ // Concatenate (not overwrite) `queryWarnings` may already carry
666
+ // the `ignored_param` warning for a stray `search_mode` pushed
667
+ // above (vault#551).
668
+ queryWarnings = queryWarnings.concat(
669
+ collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy()),
670
+ );
578
671
  if (cursorMode) {
579
672
  const page = await store.queryNotesPaged(queryOpts);
580
673
  results = page.notes;
@@ -838,7 +931,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
838
931
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
839
932
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
840
933
  - For batch: pass a \`notes\` array, each with an \`id\` field.
841
- - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
934
+ - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
842
935
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
843
936
  - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
844
937
 
@@ -967,7 +1060,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
967
1060
  },
968
1061
  execute: async (params) => {
969
1062
  const batch = params.notes as any[] | undefined;
970
- const items = batch ?? [params];
1063
+ // vault#554: top-level `force` / `if_updated_at` apply as per-item
1064
+ // DEFAULTS in a batch call — item-level values win when both are
1065
+ // present. Before this fix `items = batch ?? [params]` never merged
1066
+ // the top-level fields into batch items at all, so a caller passing
1067
+ // `{force: true, notes: [...]}` had it silently ignored: every item
1068
+ // without its OWN `force`/`if_updated_at` still threw
1069
+ // `PreconditionRequiredError` (a gardener-reported round-trip cost).
1070
+ // The single-item form (`items = [params]`) already behaved this
1071
+ // way implicitly (params IS the item), so only the batch branch
1072
+ // needs the merge.
1073
+ const items = batch
1074
+ ? batch.map((item: any) => ({
1075
+ ...(params.force !== undefined ? { force: params.force } : {}),
1076
+ ...(params.if_updated_at !== undefined ? { if_updated_at: params.if_updated_at } : {}),
1077
+ ...item,
1078
+ }))
1079
+ : [params];
971
1080
 
972
1081
  if (items.length > MAX_BATCH_SIZE) {
973
1082
  throw new BatchTooLargeError(items.length);
@@ -1099,7 +1208,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1099
1208
  // Fallthrough: not-found + no if_missing → existing error
1100
1209
  // contract. Match `requireNote`'s message shape so existing
1101
1210
  // callers see no behavior change.
1102
- throw new Error(`Note not found: "${item.id}"`);
1211
+ throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
1103
1212
  }
1104
1213
  const note = resolved;
1105
1214
 
@@ -1109,8 +1218,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1109
1218
  const hasContentEdit = item.content_edit !== undefined;
1110
1219
  const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
1111
1220
  if (contentModes > 1) {
1112
- throw new Error(
1221
+ throw structuredError(
1113
1222
  `update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
1223
+ { error_type: "mutually_exclusive", hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`" },
1114
1224
  );
1115
1225
  }
1116
1226
 
@@ -1161,20 +1271,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1161
1271
  if (hasContentEdit) {
1162
1272
  const ce = item.content_edit as { old_text: string; new_text: string };
1163
1273
  if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
1164
- throw new Error(
1274
+ throw structuredError(
1165
1275
  "update-note: `content_edit` requires { old_text: string, new_text: string }.",
1276
+ { error_type: "invalid_content_edit", field: "content_edit", hint: "pass { old_text: string, new_text: string }" },
1166
1277
  );
1167
1278
  }
1168
1279
  const idx = note.content.indexOf(ce.old_text);
1169
1280
  if (idx < 0) {
1170
- throw new Error(
1281
+ throw structuredError(
1171
1282
  `update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
1283
+ { error_type: "content_edit_not_found", field: "content_edit.old_text", hint: "re-read the note's current content and retry with an old_text that occurs exactly once" },
1172
1284
  );
1173
1285
  }
1174
1286
  const second = note.content.indexOf(ce.old_text, idx + 1);
1175
1287
  if (second >= 0) {
1176
- throw new Error(
1288
+ throw structuredError(
1177
1289
  `update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
1290
+ { error_type: "content_edit_ambiguous", field: "content_edit.old_text", hint: "add surrounding context to old_text so it matches exactly once" },
1178
1291
  );
1179
1292
  }
1180
1293
  contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
@@ -1239,8 +1352,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1239
1352
  const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1240
1353
  if (stItem !== undefined) {
1241
1354
  if (typeof stItem.field !== "string" || stItem.field.length === 0) {
1242
- throw new Error(
1355
+ throw structuredError(
1243
1356
  `update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
1357
+ { error_type: "invalid_state_transition", field: "state_transition.field", hint: "pass a non-empty string naming the metadata field to transition" },
1244
1358
  );
1245
1359
  }
1246
1360
  updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
@@ -1529,34 +1643,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1529
1643
 
1530
1644
  // Validate cross-tag consistency on fields being (re)declared in this
1531
1645
  // call. `type` and `indexed` are global — all declarers must agree.
1532
- const otherSchemas = tagSchemaOps
1533
- .listTagSchemas(db)
1534
- .filter((s) => s.tag !== tag);
1535
- for (const [fieldName, spec] of Object.entries(incomingFields)) {
1536
- const incomingIndexed = spec.indexed === true;
1537
- for (const other of otherSchemas) {
1538
- const otherSpec = other.fields?.[fieldName];
1539
- if (!otherSpec) continue;
1540
- if (otherSpec.type !== spec.type) {
1541
- throw new Error(
1542
- `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
1543
- );
1544
- }
1545
- if ((otherSpec.indexed === true) !== incomingIndexed) {
1546
- throw new Error(
1547
- `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.`,
1548
- );
1549
- }
1550
- }
1551
- if (incomingIndexed) {
1552
- const mapped = indexedFieldOps.mapFieldType(spec.type);
1553
- if (!mapped) {
1554
- throw new Error(
1555
- `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
1556
- );
1557
- }
1558
- indexedFieldOps.validateFieldName(fieldName);
1559
- }
1646
+ // vault#553/#554: collects EVERY violation instead of throwing on the
1647
+ // first — a caller declaring two bad fields sees both in one
1648
+ // response, and the thrown error states explicitly that no changes
1649
+ // were applied (nothing is persisted before this check runs).
1650
+ const fieldViolations = tagSchemaOps.collectTagFieldViolations(db, tag, incomingFields);
1651
+ if (fieldViolations.length > 0) {
1652
+ throw new tagSchemaOps.TagFieldConflictError(tag, fieldViolations);
1560
1653
  }
1561
1654
 
1562
1655
  // ---- relationships: replace wholesale when provided. `relationships`
@@ -1579,7 +1672,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1579
1672
  parentNamesPatch = null;
1580
1673
  } else if (params.parent_names !== undefined) {
1581
1674
  if (!Array.isArray(params.parent_names)) {
1582
- throw new Error("parent_names must be an array of tag names");
1675
+ throw structuredError("parent_names must be an array of tag names", {
1676
+ error_type: "invalid_parent_names",
1677
+ field: "parent_names",
1678
+ hint: "pass an array of tag name strings, or null to clear",
1679
+ });
1583
1680
  }
1584
1681
  const cleaned = (params.parent_names as unknown[])
1585
1682
  .filter((p): p is string => typeof p === "string" && p.length > 0);
@@ -1686,7 +1783,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1686
1783
  execute: () => {
1687
1784
  // This is a placeholder — vault-info needs access to vault config,
1688
1785
  // which is only available in the server layer (mcp-tools.ts).
1689
- return { error: "vault-info must be configured by the server layer" };
1786
+ return { error: "vault-info must be configured by the server layer", error_type: "not_configured" };
1690
1787
  },
1691
1788
  },
1692
1789
 
@@ -1918,6 +2015,9 @@ export class PreconditionRequiredError extends Error {
1918
2015
  */
1919
2016
  export class BatchTooLargeError extends Error {
1920
2017
  code = "BATCH_TOO_LARGE" as const;
2018
+ // Stable error_type (vault#554) — additive; matches the string REST has
2019
+ // hardcoded in its json response since #213.
2020
+ error_type = "batch_too_large" as const;
1921
2021
  limit: number;
1922
2022
  got: number;
1923
2023