@openparachute/vault 0.7.0-rc.3 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.3",
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",
@@ -4,15 +4,16 @@
4
4
  * nine-persona deep test's WS5 findings (#554) as executable tests: PASSING
5
5
  * tests lock in the structured-error precedents that already exist
6
6
  * (`path_conflict`, `conflict` with current/expected timestamps,
7
- * `precondition_required`, `schema_validation`); `test.todo` entries
8
- * describe the target behavior for confirmed-broken cases, to be flipped to
9
- * real assertions in a later wave. See #554 for the full write-up.
7
+ * `precondition_required`, `schema_validation`). The Wave 4 (#554) error
8
+ * taxonomy sweep flipped the original `test.todo` entries into real
9
+ * assertions below see that describe block.
10
10
  */
11
11
 
12
- import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
12
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
13
13
  import { Database } from "bun:sqlite";
14
14
  import { BunStore } from "./vault-store.ts";
15
- import { handleNotes } from "./routes.ts";
15
+ import { handleNotes, handleTags } from "./routes.ts";
16
+ import { generateMcpTools, PreconditionRequiredError } from "../core/src/mcp.ts";
16
17
 
17
18
  let db: Database;
18
19
  let store: BunStore;
@@ -52,6 +53,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
52
53
  const body: any = await res.json();
53
54
  expect(body.error_type).toBe("path_conflict");
54
55
  expect(body.path).toBe("taken");
56
+ expect(typeof body.hint).toBe("string");
55
57
  });
56
58
 
57
59
  it("updating with a stale if_updated_at returns 409 conflict carrying current_updated_at + your_updated_at", async () => {
@@ -62,6 +64,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
62
64
  expect(body.error_type).toBe("conflict");
63
65
  expect(body.current_updated_at).toBe(note.updatedAt);
64
66
  expect(body.your_updated_at).toBe("2020-01-01T00:00:00.000Z");
67
+ expect(typeof body.hint).toBe("string");
65
68
  });
66
69
 
67
70
  it("a strict schema violation on write returns 422 schema_validation naming every violation", async () => {
@@ -76,6 +79,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
76
79
  expect(body.violations.length).toBeGreaterThan(0);
77
80
  expect(body.violations[0].field).toBe("status");
78
81
  expect(body.violations[0].reason).toBe("enum_mismatch");
82
+ expect(typeof body.hint).toBe("string");
79
83
  });
80
84
 
81
85
  it("updating a note without if_updated_at or force returns 428 precondition_required", async () => {
@@ -85,14 +89,176 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
85
89
  const body: any = await res.json();
86
90
  expect(body.error_type).toBe("precondition_required");
87
91
  expect(body.note_id).toBe(note.id);
92
+ expect(typeof body.hint).toBe("string");
88
93
  });
89
94
  });
90
95
 
91
- describe("contract: error taxonomy — todo (#554)", () => {
92
- test.todo(
93
- "#554: every error response carries a structured {error_type, hint} pair — today error_type exists on many paths but no response carries a `hint` field, and some error paths (e.g. bad_request/ambiguous/unprocessable_content in the PATCH content_edit branch) are still bare {error, message} strings with no error_type at all",
94
- );
95
- test.todo(
96
- "#554: batch update-note honors a top-level `force` (or `if_updated_at`) applied per-item today the batch entry point does `items = batch ?? [params]`, so a top-level force:true on the request never reaches the per-item precondition check (core/src/mcp.ts ~line 1114 reads item.force only) and each item without its OWN force/if_updated_at still throws precondition_required",
97
- );
96
+ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
97
+ it("every error response on the PATCH content_edit branch carries a structured {error_type, hint} pair — previously bare {error, message} strings with no error_type at all", async () => {
98
+ const note = await store.createNote("the quick brown fox jumps over the fox", { id: "n1" });
99
+
100
+ // mutually_exclusive — content + append in the same call.
101
+ const mutEx = await patch(note.id, { content: "x", append: "y", force: true });
102
+ expect(mutEx.status).toBe(400);
103
+ const mutExBody: any = await mutEx.json();
104
+ expect(mutExBody.error_type).toBe("mutually_exclusive");
105
+ expect(typeof mutExBody.hint).toBe("string");
106
+
107
+ // invalid_content_edit — content_edit missing new_text.
108
+ const badShape = await patch(note.id, { content_edit: { old_text: "fox" }, force: true });
109
+ expect(badShape.status).toBe(400);
110
+ const badShapeBody: any = await badShape.json();
111
+ expect(badShapeBody.error_type).toBe("invalid_content_edit");
112
+ expect(badShapeBody.field).toBe("content_edit");
113
+ expect(typeof badShapeBody.hint).toBe("string");
114
+
115
+ // content_edit_not_found — old_text absent from the note's content.
116
+ const notFound = await patch(note.id, {
117
+ content_edit: { old_text: "giraffe", new_text: "zebra" },
118
+ force: true,
119
+ });
120
+ expect(notFound.status).toBe(422);
121
+ const notFoundBody: any = await notFound.json();
122
+ expect(notFoundBody.error_type).toBe("content_edit_not_found");
123
+ expect(notFoundBody.field).toBe("content_edit.old_text");
124
+ expect(typeof notFoundBody.hint).toBe("string");
125
+
126
+ // content_edit_ambiguous — old_text ("fox") matches twice in the seed content.
127
+ const ambiguous = await patch(note.id, {
128
+ content_edit: { old_text: "fox", new_text: "wolf" },
129
+ force: true,
130
+ });
131
+ expect(ambiguous.status).toBe(409);
132
+ const ambiguousBody: any = await ambiguous.json();
133
+ expect(ambiguousBody.error_type).toBe("content_edit_ambiguous");
134
+ expect(ambiguousBody.field).toBe("content_edit.old_text");
135
+ expect(typeof ambiguousBody.hint).toBe("string");
136
+
137
+ // invalid_state_transition — state_transition.field is an empty string.
138
+ const badTransition = await patch(note.id, { state_transition: { field: "", from: "a", to: "b" } });
139
+ expect(badTransition.status).toBe(400);
140
+ const badTransitionBody: any = await badTransition.json();
141
+ expect(badTransitionBody.error_type).toBe("invalid_state_transition");
142
+ expect(badTransitionBody.field).toBe("state_transition.field");
143
+ expect(typeof badTransitionBody.hint).toBe("string");
144
+ });
145
+
146
+ it("batch update-note honors a top-level `force` applied per-item as a DEFAULT, and an item's own value still wins", async () => {
147
+ const a = await store.createNote("a", { id: "a1" });
148
+ const b = await store.createNote("b", { id: "b1" });
149
+ const tools = generateMcpTools(store);
150
+ const updateNote = tools.find((t) => t.name === "update-note")!;
151
+
152
+ // Top-level force:true, item omits its own force/if_updated_at — the
153
+ // default applies and the write succeeds instead of throwing
154
+ // precondition_required. Before the fix, `items = batch ?? [params]`
155
+ // never merged the top-level field in, so this ALWAYS threw.
156
+ const result: any = await updateNote.execute({
157
+ force: true,
158
+ notes: [{ id: a.id, content: "a-updated" }],
159
+ });
160
+ expect(result[0].content).toBe("a-updated");
161
+
162
+ // Item-level values still win over the top-level default: this item
163
+ // explicitly sets `force: false` (overriding the batch default) and
164
+ // supplies no `if_updated_at` of its own, so the precondition gate
165
+ // still fires for it.
166
+ let caught: unknown;
167
+ try {
168
+ await updateNote.execute({
169
+ force: true,
170
+ notes: [{ id: b.id, content: "b-updated", force: false }],
171
+ });
172
+ } catch (e) {
173
+ caught = e;
174
+ }
175
+ expect(caught).toBeInstanceOf(PreconditionRequiredError);
176
+ });
177
+
178
+ it("REST PUT /api/tags/:name reports ALL cross-tag field violations in one call and states no changes were applied (#553 messaging, mirrors the MCP tool)", async () => {
179
+ // Tag "a" declares two fields; tag "b" redeclares BOTH with conflicting
180
+ // specs in the SAME PUT — a NON-indexed type conflict on "x" and an
181
+ // indexed-flag conflict on "y" (both were silent 200s on main; the
182
+ // both-indexed type-conflict case deliberately keeps its pre-existing
183
+ // 400 path — see the regression test below). Before this fix REST had
184
+ // no cross-tag pre-check at all here (a gap distinct from the MCP
185
+ // tool's old first-field-only throw); now both surfaces report
186
+ // identically.
187
+ await store.upsertTagRecord("a", {
188
+ fields: {
189
+ x: { type: "string" },
190
+ y: { type: "boolean", indexed: true },
191
+ },
192
+ });
193
+
194
+ const req = new Request("http://localhost/api/tags/b", {
195
+ method: "PUT",
196
+ body: JSON.stringify({
197
+ fields: {
198
+ x: { type: "integer" },
199
+ y: { type: "boolean", indexed: false },
200
+ },
201
+ }),
202
+ });
203
+ const res = await handleTags(req, store, "/b");
204
+ expect(res.status).toBe(422);
205
+ const body: any = await res.json();
206
+ expect(body.error_type).toBe("tag_field_conflict");
207
+ expect(body.violations).toHaveLength(2);
208
+ const byField = new Map(body.violations.map((v: any) => [v.field, v.reason]));
209
+ expect(byField.get("x")).toBe("type_conflict");
210
+ expect(byField.get("y")).toBe("indexed_flag_conflict");
211
+ expect(body.message).toContain("no changes were applied");
212
+ // Full detail for an unscoped caller — the conflicting declarer is named.
213
+ expect(body.violations[0].other_tag).toBe("a");
214
+
215
+ // Nothing partially landed.
216
+ const bRecord = await store.getTagRecord("b");
217
+ expect(bRecord?.fields ?? null).toBeFalsy();
218
+ });
219
+
220
+ it("REST PUT /api/tags/:name still returns 400 invalid_indexed_field for a solo bad field name (vault#478 contract unchanged)", async () => {
221
+ // No cross-tag conflict here — "meeting-type" is invalid on its OWN
222
+ // (kebab-case). This must stay on the pre-existing 400/invalid_indexed_field
223
+ // path, NOT the new 422/tag_field_conflict path — the cross-tag pre-check
224
+ // is deliberately scoped to type/indexed-flag agreement only (see
225
+ // `collectCrossTagFieldViolations`'s doc comment in core/src/tag-schemas.ts).
226
+ const req = new Request("http://localhost/api/tags/meeting", {
227
+ method: "PUT",
228
+ body: JSON.stringify({ fields: { "meeting-type": { type: "string", indexed: true } } }),
229
+ });
230
+ const res = await handleTags(req, store, "/meeting");
231
+ expect(res.status).toBe(400);
232
+ const body: any = await res.json();
233
+ expect(body.error_type).toBe("invalid_indexed_field");
234
+ });
235
+
236
+ it("REST PUT /api/tags/:name: a BOTH-INDEXED cross-tag type conflict stays 400 invalid_indexed_field, NOT 422 (wire-contract floor — pre-existing declareField behavior)", async () => {
237
+ // Exact regression from the wire review: tag "a" declares x
238
+ // string+indexed; tag "b" PUTs x integer+indexed. On main this
239
+ // returned 400 invalid_indexed_field (declareField's cross-declarer
240
+ // sqlite-type check inside store.upsertTagRecord); the vault#554
241
+ // pre-check must NOT intercept it as a 422 — statuses/error_types on
242
+ // previously-working calls are wire contract. See
243
+ // `collectCrossTagFieldViolations`'s doc-comment exclusion 2.
244
+ await store.upsertTagRecord("a", {
245
+ fields: { x: { type: "string", indexed: true } },
246
+ });
247
+
248
+ const req = new Request("http://localhost/api/tags/b", {
249
+ method: "PUT",
250
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
251
+ });
252
+ const res = await handleTags(req, store, "/b");
253
+ expect(res.status).toBe(400);
254
+ const body: any = await res.json();
255
+ expect(body.error_type).toBe("invalid_indexed_field");
256
+ // Unscoped caller: declareField's full message (naming the declarer)
257
+ // is preserved verbatim.
258
+ expect(body.error).toContain("tag(s) [a]");
259
+
260
+ // Nothing persisted — the store's transaction rolled back.
261
+ const bRecord = await store.getTagRecord("b");
262
+ expect(bRecord?.fields ?? null).toBeFalsy();
263
+ });
98
264
  });
package/src/mcp-http.ts CHANGED
@@ -139,13 +139,18 @@ async function handleMcp(
139
139
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
140
140
  };
141
141
  } catch (err) {
142
- // Domain errors from the core tools (conflict, missing precondition) get
143
- // surfaced as JSON-RPC errors with a structured `data` field so an
144
- // agent can key off `data.error_type` and the concurrency tokens.
145
- // Everything else falls through to an in-band tool error with
146
- // `isError: true` legible but unstructured.
142
+ // Domain errors from the core tools (conflict, missing precondition,
143
+ // path collisions, batch caps, cursor/query validation, tag-field
144
+ // conflicts, ...) get surfaced as JSON-RPC errors with a structured
145
+ // `data` field so an agent can key off `data.error_type` and any
146
+ // error-specific fields (concurrency tokens, violations, candidates,
147
+ // ...). vault#554: every domain error class is mapped below, either by
148
+ // a dedicated branch (full field fidelity) or the generic `error_type`
149
+ // catch-all near the end. Only a TRULY unknown error (no `error_type`
150
+ // anywhere on it) falls through to the in-band `isError: true` text.
147
151
  const message = err instanceof Error ? err.message : "Unknown error";
148
152
  const e = err as {
153
+ name?: string;
149
154
  code?: string;
150
155
  note_id?: string;
151
156
  note_path?: string | null;
@@ -159,6 +164,12 @@ async function handleMcp(
159
164
  error_type?: string;
160
165
  got?: unknown;
161
166
  hint?: string;
167
+ path?: string;
168
+ candidates?: unknown;
169
+ extension?: string;
170
+ reason?: string;
171
+ limit?: number;
172
+ tag?: string;
162
173
  };
163
174
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
164
175
  // values that are structurally invalid rather than merely "no
@@ -196,6 +207,7 @@ async function handleMcp(
196
207
  your_updated_at: e.expected_updated_at,
197
208
  path: e.note_path ?? null,
198
209
  note_id: e.note_id,
210
+ hint: "re-read the note (query-notes) and re-apply your change against its current updated_at, or pass force: true to overwrite",
199
211
  });
200
212
  }
201
213
  // State-transition compare-and-set conflict (vault#299 Part B) — a
@@ -210,6 +222,7 @@ async function handleMcp(
210
222
  expected_from: e.expected_from,
211
223
  to: e.to,
212
224
  current: e.current ?? null,
225
+ hint: "re-read the note's current value for this field and retry the transition from its actual current state",
213
226
  });
214
227
  }
215
228
  // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
@@ -218,6 +231,7 @@ async function handleMcp(
218
231
  throw new McpError(ErrorCode.InvalidParams, message, {
219
232
  error_type: "schema_validation",
220
233
  violations: e.violations ?? [],
234
+ hint: "fix every field listed in violations and retry — none of this write was applied",
221
235
  });
222
236
  }
223
237
  if (e?.code === "PRECONDITION_REQUIRED") {
@@ -225,6 +239,96 @@ async function handleMcp(
225
239
  error_type: "precondition_required",
226
240
  note_id: e.note_id,
227
241
  path: e.note_path ?? null,
242
+ hint: "re-read the note, pass its updated_at as if_updated_at, or pass force: true to skip the check",
243
+ });
244
+ }
245
+ // Any other QueryError (vault#554) — FIELD_NOT_INDEXED, UNKNOWN_OPERATOR,
246
+ // INVALID_OPERATOR_VALUE, and the various cursor/near/search
247
+ // incompatibility throws that predate the vault#550/#551 `error_type`
248
+ // convention (which is why they weren't caught by the two branches
249
+ // above). Falls back to `error_type: "invalid_query"` — the umbrella
250
+ // REST already uses for this whole error class — so these no longer
251
+ // fall through to the unstructured `isError: true` text.
252
+ if (e?.name === "QueryError") {
253
+ throw new McpError(ErrorCode.InvalidParams, message, {
254
+ error_type: e.error_type ?? "invalid_query",
255
+ code: e.code,
256
+ field: e.field,
257
+ got: e.got,
258
+ hint: e.hint,
259
+ });
260
+ }
261
+ // Malformed / stale opaque cursor (vault#313, vault#554) — was
262
+ // entirely unmapped here before (REST already returns `{error, code}`
263
+ // — no `error_type` either; both surfaces get one now). `code` is
264
+ // already the exact `error_type` vocabulary: "cursor_invalid" or
265
+ // "cursor_query_mismatch".
266
+ if (e?.name === "CursorError" && typeof e.code === "string") {
267
+ throw new McpError(ErrorCode.InvalidParams, message, {
268
+ error_type: e.code,
269
+ });
270
+ }
271
+ // Path-rename/create collision (vault#126, vault#554) — schema's
272
+ // UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
273
+ if (e?.code === "PATH_CONFLICT") {
274
+ throw new McpError(ErrorCode.InvalidRequest, message, {
275
+ error_type: "path_conflict",
276
+ path: e.path,
277
+ });
278
+ }
279
+ // A path lookup matched more than one note (vault#330 S1, vault#554) —
280
+ // mirrors REST's 409 `ambiguous_path` shape (candidates = the
281
+ // disambiguating extensions).
282
+ if (e?.code === "AMBIGUOUS_PATH") {
283
+ throw new McpError(ErrorCode.InvalidRequest, message, {
284
+ error_type: "ambiguous_path",
285
+ path: e.path,
286
+ candidates: e.candidates,
287
+ });
288
+ }
289
+ // Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
290
+ // `invalid_extension` shape.
291
+ if (e?.code === "INVALID_EXTENSION") {
292
+ throw new McpError(ErrorCode.InvalidParams, message, {
293
+ error_type: "invalid_extension",
294
+ extension: e.extension,
295
+ reason: e.reason,
296
+ });
297
+ }
298
+ // Batch cap exceeded (#213, vault#554) — mirrors REST's 413
299
+ // `batch_too_large` shape.
300
+ if (e?.code === "BATCH_TOO_LARGE") {
301
+ throw new McpError(ErrorCode.InvalidRequest, message, {
302
+ error_type: "batch_too_large",
303
+ limit: e.limit,
304
+ got: e.got,
305
+ });
306
+ }
307
+ // update-tag cross-tag field conflicts (vault#553/#554) — carries
308
+ // EVERY violation in one response (see `TagFieldConflictError`).
309
+ if (e?.code === "TAG_FIELD_CONFLICT") {
310
+ throw new McpError(ErrorCode.InvalidParams, message, {
311
+ error_type: "tag_field_conflict",
312
+ tag: e.tag,
313
+ violations: e.violations ?? [],
314
+ });
315
+ }
316
+ // Generic catch-all (vault#554): any remaining error that carries a
317
+ // stable `error_type` — either a domain error class that stamps it as
318
+ // an instance property (IndexedFieldError, and the classes handled by
319
+ // dedicated branches above as defense-in-depth) or a validation leaf
320
+ // built with `structuredError()` in core/src/mcp.ts (mutually_exclusive,
321
+ // invalid_content_edit, content_edit_not_found, content_edit_ambiguous,
322
+ // invalid_state_transition, invalid_parent_names, invalid_relationships,
323
+ // not_found, ...). This is the backstop that makes "nothing falls
324
+ // through to the unstructured isError text except a TRULY unknown
325
+ // error" true by construction: only errors nobody tagged with
326
+ // `error_type` still hit the fallback below.
327
+ if (typeof e?.error_type === "string") {
328
+ throw new McpError(ErrorCode.InvalidParams, message, {
329
+ error_type: e.error_type,
330
+ field: e.field,
331
+ hint: e.hint,
228
332
  });
229
333
  }
230
334
  return {
@@ -151,4 +151,40 @@ describe("MCP list-tags single-tag path — tag-scope enforcement (vault#550 fol
151
151
  expect(names).toContain("health");
152
152
  expect(names).not.toContain("work");
153
153
  });
154
+
155
+ // vault#554 carry-forward: the wrapper's SECOND scrub branch (the one
156
+ // AFTER the early out-of-scope short-circuit above) fires when the queried
157
+ // tag itself IS in the allowlist but core still reports tag_not_found —
158
+ // the hollow-tag case (vault#550: no identity row, no notes directly
159
+ // carrying it) reached ONLY via a child's `parent_names`. Core computes
160
+ // `did_you_mean` vault-wide (scope-unaware by architecture), so it can
161
+ // name an out-of-scope tag; the wrapper must strip that suggestion even
162
+ // though the queried tag itself passed the allowlist gate. This branch was
163
+ // execution-verified in a prior review; this test commits it.
164
+ test("scoped token + in-scope HOLLOW tag (reachable only via a child's parent_names) scrubs an out-of-scope did_you_mean", async () => {
165
+ seedVault("journal");
166
+ const store = getVaultStore("journal");
167
+
168
+ // "health" itself has NO identity row and NO note directly carrying it —
169
+ // it's in the allowlist expansion ONLY because "kale" declares it as a
170
+ // parent. "kale" is deliberately NOT lexically close to "health" (the
171
+ // mission's "non-close child") so it can't win did_you_mean and mask
172
+ // the bug this test targets.
173
+ await store.upsertTagRecord("kale", { parent_names: ["health"] });
174
+ await store.createNote("k", { tags: ["kale"] });
175
+
176
+ // Out-of-scope decoy, lexically close to "health" (prefix match) — this
177
+ // is what core's vault-wide suggestSimilarTag would pick if nothing
178
+ // scrubbed it.
179
+ await store.createNote("hy", { tags: ["healthy"] });
180
+
181
+ const tool = await listTagsTool("journal", ["health"]);
182
+ const result = (await tool.execute({ tag: "health" })) as any;
183
+
184
+ expect(result.error_type).toBe("tag_not_found");
185
+ expect(result.tag).toBe("health");
186
+ // The scrub: did_you_mean must NOT leak the out-of-scope "healthy",
187
+ // even though "health" (the query) itself passed the allowlist gate.
188
+ expect(result.did_you_mean).toBeUndefined();
189
+ });
154
190
  });