@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.
@@ -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
@@ -10,7 +10,6 @@ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMo
10
10
  import * as linkOps from "./links.js";
11
11
  import * as tagSchemaOps from "./tag-schemas.js";
12
12
  import type { TagFieldSchema } from "./tag-schemas.js";
13
- import * as indexedFieldOps from "./indexed-fields.js";
14
13
  import {
15
14
  SchemaValidationError,
16
15
  strictViolations,
@@ -53,6 +52,22 @@ export interface McpToolDef {
53
52
  // Helpers
54
53
  // ---------------------------------------------------------------------------
55
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
+
56
71
  /**
57
72
  * Resolve a note identifier — tries ID first, then case-insensitive
58
73
  * path match. Works everywhere a note reference is accepted.
@@ -86,7 +101,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
86
101
 
87
102
  function requireNote(db: Database, idOrPath: string): Note {
88
103
  const note = resolveNote(db, idOrPath);
89
- 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
+ }
90
107
  return note;
91
108
  }
92
109
 
@@ -423,7 +440,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
423
440
  // --- Single note by ID/path ---
424
441
  if (params.id) {
425
442
  const note = resolveNote(db, params.id as string);
426
- 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 };
427
444
  const includeContent = params.include_content !== false; // default true for single
428
445
  // Range params are meaningless on a content-less shape — error
429
446
  // rather than silently ignore (same loud-validation policy as
@@ -471,7 +488,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
471
488
  if (params.near) {
472
489
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
473
490
  const anchor = resolveNote(db, near.note_id);
474
- 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 };
475
492
  const depth = Math.min(near.depth ?? 2, 5);
476
493
  const traversed = linkOps.traverseLinks(db, anchor.id, {
477
494
  max_depth: depth,
@@ -914,7 +931,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
914
931
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
915
932
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
916
933
  - For batch: pass a \`notes\` array, each with an \`id\` field.
917
- - **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.
918
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.
919
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.
920
937
 
@@ -1043,7 +1060,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1043
1060
  },
1044
1061
  execute: async (params) => {
1045
1062
  const batch = params.notes as any[] | undefined;
1046
- 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];
1047
1080
 
1048
1081
  if (items.length > MAX_BATCH_SIZE) {
1049
1082
  throw new BatchTooLargeError(items.length);
@@ -1175,7 +1208,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1175
1208
  // Fallthrough: not-found + no if_missing → existing error
1176
1209
  // contract. Match `requireNote`'s message shape so existing
1177
1210
  // callers see no behavior change.
1178
- throw new Error(`Note not found: "${item.id}"`);
1211
+ throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
1179
1212
  }
1180
1213
  const note = resolved;
1181
1214
 
@@ -1185,8 +1218,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1185
1218
  const hasContentEdit = item.content_edit !== undefined;
1186
1219
  const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
1187
1220
  if (contentModes > 1) {
1188
- throw new Error(
1221
+ throw structuredError(
1189
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`" },
1190
1224
  );
1191
1225
  }
1192
1226
 
@@ -1237,20 +1271,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1237
1271
  if (hasContentEdit) {
1238
1272
  const ce = item.content_edit as { old_text: string; new_text: string };
1239
1273
  if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
1240
- throw new Error(
1274
+ throw structuredError(
1241
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 }" },
1242
1277
  );
1243
1278
  }
1244
1279
  const idx = note.content.indexOf(ce.old_text);
1245
1280
  if (idx < 0) {
1246
- throw new Error(
1281
+ throw structuredError(
1247
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" },
1248
1284
  );
1249
1285
  }
1250
1286
  const second = note.content.indexOf(ce.old_text, idx + 1);
1251
1287
  if (second >= 0) {
1252
- throw new Error(
1288
+ throw structuredError(
1253
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" },
1254
1291
  );
1255
1292
  }
1256
1293
  contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
@@ -1315,8 +1352,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1315
1352
  const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1316
1353
  if (stItem !== undefined) {
1317
1354
  if (typeof stItem.field !== "string" || stItem.field.length === 0) {
1318
- throw new Error(
1355
+ throw structuredError(
1319
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" },
1320
1358
  );
1321
1359
  }
1322
1360
  updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
@@ -1605,34 +1643,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1605
1643
 
1606
1644
  // Validate cross-tag consistency on fields being (re)declared in this
1607
1645
  // call. `type` and `indexed` are global — all declarers must agree.
1608
- const otherSchemas = tagSchemaOps
1609
- .listTagSchemas(db)
1610
- .filter((s) => s.tag !== tag);
1611
- for (const [fieldName, spec] of Object.entries(incomingFields)) {
1612
- const incomingIndexed = spec.indexed === true;
1613
- for (const other of otherSchemas) {
1614
- const otherSpec = other.fields?.[fieldName];
1615
- if (!otherSpec) continue;
1616
- if (otherSpec.type !== spec.type) {
1617
- throw new Error(
1618
- `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
1619
- );
1620
- }
1621
- if ((otherSpec.indexed === true) !== incomingIndexed) {
1622
- throw new Error(
1623
- `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.`,
1624
- );
1625
- }
1626
- }
1627
- if (incomingIndexed) {
1628
- const mapped = indexedFieldOps.mapFieldType(spec.type);
1629
- if (!mapped) {
1630
- throw new Error(
1631
- `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
1632
- );
1633
- }
1634
- indexedFieldOps.validateFieldName(fieldName);
1635
- }
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);
1636
1653
  }
1637
1654
 
1638
1655
  // ---- relationships: replace wholesale when provided. `relationships`
@@ -1655,7 +1672,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1655
1672
  parentNamesPatch = null;
1656
1673
  } else if (params.parent_names !== undefined) {
1657
1674
  if (!Array.isArray(params.parent_names)) {
1658
- 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
+ });
1659
1680
  }
1660
1681
  const cleaned = (params.parent_names as unknown[])
1661
1682
  .filter((p): p is string => typeof p === "string" && p.length > 0);
@@ -1762,7 +1783,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1762
1783
  execute: () => {
1763
1784
  // This is a placeholder — vault-info needs access to vault config,
1764
1785
  // which is only available in the server layer (mcp-tools.ts).
1765
- 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" };
1766
1787
  },
1767
1788
  },
1768
1789
 
@@ -1994,6 +2015,9 @@ export class PreconditionRequiredError extends Error {
1994
2015
  */
1995
2016
  export class BatchTooLargeError extends Error {
1996
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;
1997
2021
  limit: number;
1998
2022
  got: number;
1999
2023
 
package/core/src/notes.ts CHANGED
@@ -284,6 +284,11 @@ export class TransitionConflictError extends Error {
284
284
  */
285
285
  export class PathConflictError extends Error {
286
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;
287
292
  path: string;
288
293
 
289
294
  constructor(path: string) {
@@ -306,6 +311,8 @@ export class PathConflictError extends Error {
306
311
  */
307
312
  export class AmbiguousPathError extends Error {
308
313
  code = "AMBIGUOUS_PATH" as const;
314
+ // Stable error_type (vault#554) — see PathConflictError's comment.
315
+ error_type = "ambiguous_path" as const;
309
316
  path: string;
310
317
  candidates: { id: string; extension: string }[];
311
318
 
@@ -346,6 +353,8 @@ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
346
353
 
347
354
  export class ExtensionValidationError extends Error {
348
355
  code = "INVALID_EXTENSION" as const;
356
+ // Stable error_type (vault#554) — see PathConflictError's comment.
357
+ error_type = "invalid_extension" as const;
349
358
  extension: string;
350
359
  reason: string;
351
360