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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/mcp.ts CHANGED
@@ -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
 
@@ -173,9 +190,11 @@ export interface GenerateMcpToolsOpts {
173
190
  }
174
191
 
175
192
  /**
176
- * Generate the consolidated MCP tools for a vault. Surface (10):
193
+ * Generate the consolidated MCP tools for a vault. Surface (13):
177
194
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
178
- * delete-tag, find-path, vault-info, prune-schema (admin).
195
+ * delete-tag, rename-tag, merge-tags, find-path, vault-info, prune-schema
196
+ * (admin), doctor (admin). `manage-token` (admin) is appended by the SERVER
197
+ * layer (src/mcp-tools.ts), not here — see that file's doc comment.
179
198
  */
180
199
  export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
181
200
  const db: Database = store.db;
@@ -423,7 +442,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
423
442
  // --- Single note by ID/path ---
424
443
  if (params.id) {
425
444
  const note = resolveNote(db, params.id as string);
426
- if (!note) return { error: "Note not found", id: params.id };
445
+ if (!note) return { error: "Note not found", error_type: "not_found", id: params.id };
427
446
  const includeContent = params.include_content !== false; // default true for single
428
447
  // Range params are meaningless on a content-less shape — error
429
448
  // rather than silently ignore (same loud-validation policy as
@@ -471,7 +490,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
471
490
  if (params.near) {
472
491
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
473
492
  const anchor = resolveNote(db, near.note_id);
474
- if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
493
+ if (!anchor) return { error: "Anchor note not found", error_type: "not_found", note_id: near.note_id };
475
494
  const depth = Math.min(near.depth ?? 2, 5);
476
495
  const traversed = linkOps.traverseLinks(db, anchor.id, {
477
496
  max_depth: depth,
@@ -914,7 +933,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
914
933
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
915
934
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
916
935
  - 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).
936
+ - **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
937
  - **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
938
  - \`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
939
 
@@ -1043,7 +1062,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1043
1062
  },
1044
1063
  execute: async (params) => {
1045
1064
  const batch = params.notes as any[] | undefined;
1046
- const items = batch ?? [params];
1065
+ // vault#554: top-level `force` / `if_updated_at` apply as per-item
1066
+ // DEFAULTS in a batch call — item-level values win when both are
1067
+ // present. Before this fix `items = batch ?? [params]` never merged
1068
+ // the top-level fields into batch items at all, so a caller passing
1069
+ // `{force: true, notes: [...]}` had it silently ignored: every item
1070
+ // without its OWN `force`/`if_updated_at` still threw
1071
+ // `PreconditionRequiredError` (a gardener-reported round-trip cost).
1072
+ // The single-item form (`items = [params]`) already behaved this
1073
+ // way implicitly (params IS the item), so only the batch branch
1074
+ // needs the merge.
1075
+ const items = batch
1076
+ ? batch.map((item: any) => ({
1077
+ ...(params.force !== undefined ? { force: params.force } : {}),
1078
+ ...(params.if_updated_at !== undefined ? { if_updated_at: params.if_updated_at } : {}),
1079
+ ...item,
1080
+ }))
1081
+ : [params];
1047
1082
 
1048
1083
  if (items.length > MAX_BATCH_SIZE) {
1049
1084
  throw new BatchTooLargeError(items.length);
@@ -1175,7 +1210,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1175
1210
  // Fallthrough: not-found + no if_missing → existing error
1176
1211
  // contract. Match `requireNote`'s message shape so existing
1177
1212
  // callers see no behavior change.
1178
- throw new Error(`Note not found: "${item.id}"`);
1213
+ throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
1179
1214
  }
1180
1215
  const note = resolved;
1181
1216
 
@@ -1185,8 +1220,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1185
1220
  const hasContentEdit = item.content_edit !== undefined;
1186
1221
  const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
1187
1222
  if (contentModes > 1) {
1188
- throw new Error(
1223
+ throw structuredError(
1189
1224
  `update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
1225
+ { error_type: "mutually_exclusive", hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`" },
1190
1226
  );
1191
1227
  }
1192
1228
 
@@ -1237,20 +1273,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1237
1273
  if (hasContentEdit) {
1238
1274
  const ce = item.content_edit as { old_text: string; new_text: string };
1239
1275
  if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
1240
- throw new Error(
1276
+ throw structuredError(
1241
1277
  "update-note: `content_edit` requires { old_text: string, new_text: string }.",
1278
+ { error_type: "invalid_content_edit", field: "content_edit", hint: "pass { old_text: string, new_text: string }" },
1242
1279
  );
1243
1280
  }
1244
1281
  const idx = note.content.indexOf(ce.old_text);
1245
1282
  if (idx < 0) {
1246
- throw new Error(
1283
+ throw structuredError(
1247
1284
  `update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
1285
+ { 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
1286
  );
1249
1287
  }
1250
1288
  const second = note.content.indexOf(ce.old_text, idx + 1);
1251
1289
  if (second >= 0) {
1252
- throw new Error(
1290
+ throw structuredError(
1253
1291
  `update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
1292
+ { error_type: "content_edit_ambiguous", field: "content_edit.old_text", hint: "add surrounding context to old_text so it matches exactly once" },
1254
1293
  );
1255
1294
  }
1256
1295
  contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
@@ -1315,8 +1354,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1315
1354
  const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1316
1355
  if (stItem !== undefined) {
1317
1356
  if (typeof stItem.field !== "string" || stItem.field.length === 0) {
1318
- throw new Error(
1357
+ throw structuredError(
1319
1358
  `update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
1359
+ { error_type: "invalid_state_transition", field: "state_transition.field", hint: "pass a non-empty string naming the metadata field to transition" },
1320
1360
  );
1321
1361
  }
1322
1362
  updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
@@ -1605,34 +1645,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1605
1645
 
1606
1646
  // Validate cross-tag consistency on fields being (re)declared in this
1607
1647
  // 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
- }
1648
+ // vault#553/#554: collects EVERY violation instead of throwing on the
1649
+ // first — a caller declaring two bad fields sees both in one
1650
+ // response, and the thrown error states explicitly that no changes
1651
+ // were applied (nothing is persisted before this check runs).
1652
+ const fieldViolations = tagSchemaOps.collectTagFieldViolations(db, tag, incomingFields);
1653
+ if (fieldViolations.length > 0) {
1654
+ throw new tagSchemaOps.TagFieldConflictError(tag, fieldViolations);
1636
1655
  }
1637
1656
 
1638
1657
  // ---- relationships: replace wholesale when provided. `relationships`
@@ -1655,7 +1674,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1655
1674
  parentNamesPatch = null;
1656
1675
  } else if (params.parent_names !== undefined) {
1657
1676
  if (!Array.isArray(params.parent_names)) {
1658
- throw new Error("parent_names must be an array of tag names");
1677
+ throw structuredError("parent_names must be an array of tag names", {
1678
+ error_type: "invalid_parent_names",
1679
+ field: "parent_names",
1680
+ hint: "pass an array of tag name strings, or null to clear",
1681
+ });
1659
1682
  }
1660
1683
  const cleaned = (params.parent_names as unknown[])
1661
1684
  .filter((p): p is string => typeof p === "string" && p.length > 0);
@@ -1695,11 +1718,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1695
1718
  // mgmt + future config writes; deletes are write-tier mutations.
1696
1719
  // See delete-note rationale.
1697
1720
  requiredVerb: "write",
1698
- description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
1721
+ description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
1699
1722
  inputSchema: {
1700
1723
  type: "object",
1701
1724
  properties: {
1702
1725
  tag: { type: "string", description: "Tag name to delete" },
1726
+ cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
1727
+ detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
1703
1728
  },
1704
1729
  required: ["tag"],
1705
1730
  },
@@ -1710,8 +1735,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1710
1735
  // Indexed-field release is handled inside store.deleteTag →
1711
1736
  // noteOps.deleteTag so every entry point (MCP, REST, import sweep)
1712
1737
  // releases consistently with the co-declaration guard. See the
1713
- // gitcoin orphaned-fields bug report.
1714
- return await store.deleteTag(tag);
1738
+ // gitcoin orphaned-fields bug report. The referential-integrity
1739
+ // guard (vault#552) is ALSO inside store.deleteTag, so it returns
1740
+ // (not throws) `{error: "tag_referenced_as_parent", ...}` when
1741
+ // refused — same in-band shape delete-tag has always used for its
1742
+ // token-reference guard (applyTagDependencyGuards, src/mcp-tools.ts).
1743
+ return await store.deleteTag(tag, {
1744
+ cascade: params.cascade === true,
1745
+ detach: params.detach === true,
1746
+ });
1747
+ },
1748
+ },
1749
+
1750
+ // =====================================================================
1751
+ // 7a. rename-tag — atomic cascading rename (vault#552 MCP parity)
1752
+ // =====================================================================
1753
+ {
1754
+ name: "rename-tag",
1755
+ requiredVerb: "write",
1756
+ description:
1757
+ "Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
1758
+ inputSchema: {
1759
+ type: "object",
1760
+ properties: {
1761
+ old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
1762
+ new_name: { type: "string", description: "The new name. Alias: to." },
1763
+ from: { type: "string", description: "Alias for old_name." },
1764
+ to: { type: "string", description: "Alias for new_name." },
1765
+ tag: { type: "string", description: "Alias for old_name." },
1766
+ },
1767
+ },
1768
+ execute: async (params) => {
1769
+ const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
1770
+ const newName = (params.new_name ?? params.to) as string | undefined;
1771
+ if (typeof oldName !== "string" || oldName.length === 0) {
1772
+ throw structuredError("rename-tag: old_name (or from/tag) is required", {
1773
+ error_type: "invalid_request",
1774
+ field: "old_name",
1775
+ });
1776
+ }
1777
+ if (typeof newName !== "string" || newName.length === 0) {
1778
+ throw structuredError("rename-tag: new_name (or to) is required", {
1779
+ error_type: "invalid_request",
1780
+ field: "new_name",
1781
+ });
1782
+ }
1783
+ const result = await store.renameTag(oldName, newName);
1784
+ if ("error" in result) {
1785
+ if (result.error === "not_found") {
1786
+ throw structuredError(`rename-tag: tag "${oldName}" not found`, {
1787
+ error_type: "tag_not_found",
1788
+ field: "old_name",
1789
+ });
1790
+ }
1791
+ throw Object.assign(
1792
+ new Error(
1793
+ `rename-tag: target "${newName}" (or one of its sub-tags) already exists — use merge-tags to combine them instead`,
1794
+ ),
1795
+ {
1796
+ error_type: "target_exists" as const,
1797
+ target: newName,
1798
+ conflicting: result.conflicting,
1799
+ hint: "use merge-tags to combine the tags instead",
1800
+ },
1801
+ );
1802
+ }
1803
+ return result;
1804
+ },
1805
+ },
1806
+
1807
+ // =====================================================================
1808
+ // 7b. merge-tags — same machinery, N sources → one target
1809
+ // =====================================================================
1810
+ {
1811
+ name: "merge-tags",
1812
+ requiredVerb: "write",
1813
+ description:
1814
+ "Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
1815
+ inputSchema: {
1816
+ type: "object",
1817
+ properties: {
1818
+ sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
1819
+ target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
1820
+ },
1821
+ required: ["sources", "target"],
1822
+ },
1823
+ execute: async (params) => {
1824
+ const sources = params.sources;
1825
+ const target = params.target;
1826
+ if (!Array.isArray(sources) || sources.length === 0 || !sources.every((s) => typeof s === "string" && s.length > 0)) {
1827
+ throw structuredError("merge-tags: sources must be a non-empty array of strings", {
1828
+ error_type: "invalid_request",
1829
+ field: "sources",
1830
+ });
1831
+ }
1832
+ if (typeof target !== "string" || target.length === 0) {
1833
+ throw structuredError("merge-tags: target must be a non-empty string", {
1834
+ error_type: "invalid_request",
1835
+ field: "target",
1836
+ });
1837
+ }
1838
+ return await store.mergeTags(sources as string[], target);
1715
1839
  },
1716
1840
  },
1717
1841
 
@@ -1762,7 +1886,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1762
1886
  execute: () => {
1763
1887
  // This is a placeholder — vault-info needs access to vault config,
1764
1888
  // which is only available in the server layer (mcp-tools.ts).
1765
- return { error: "vault-info must be configured by the server layer" };
1889
+ return { error: "vault-info must be configured by the server layer", error_type: "not_configured" };
1766
1890
  },
1767
1891
  },
1768
1892
 
@@ -1801,6 +1925,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1801
1925
  },
1802
1926
  },
1803
1927
 
1928
+ // =====================================================================
1929
+ // 11. doctor — read-only taxonomy/metadata integrity scan (vault#552)
1930
+ // =====================================================================
1931
+ {
1932
+ name: "doctor",
1933
+ // `admin` — same tier as prune-schema: a diagnostic over the WHOLE
1934
+ // vault's taxonomy, not scoped to any one tag's write authority.
1935
+ requiredVerb: "admin",
1936
+ description:
1937
+ "Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Reports, per finding, {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
1938
+ inputSchema: { type: "object", properties: {} },
1939
+ execute: async () => {
1940
+ return await store.doctor();
1941
+ },
1942
+ },
1943
+
1804
1944
  ];
1805
1945
  }
1806
1946
 
@@ -1994,6 +2134,9 @@ export class PreconditionRequiredError extends Error {
1994
2134
  */
1995
2135
  export class BatchTooLargeError extends Error {
1996
2136
  code = "BATCH_TOO_LARGE" as const;
2137
+ // Stable error_type (vault#554) — additive; matches the string REST has
2138
+ // hardcoded in its json response since #213.
2139
+ error_type = "batch_too_large" as const;
1997
2140
  limit: number;
1998
2141
  got: number;
1999
2142
 
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
 
@@ -1503,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
1503
1512
  return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
1504
1513
  }
1505
1514
 
1506
- export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
1515
+ /** Options accepted by {@link deleteTag} (vault#552). */
1516
+ export interface DeleteTagOpts {
1517
+ /**
1518
+ * Proceed with the delete even though another tag's `parent_names`
1519
+ * references this one — strip the reference from every referencing tag's
1520
+ * `parent_names` array as part of the same transaction. Accepted as a
1521
+ * synonym of `detach` (both name the identical repair: remove the stale
1522
+ * reference, never delete the referencing tag itself); offered as two
1523
+ * flags because operators reach for either word depending on whether
1524
+ * they're thinking "cascade the delete" or "detach the children."
1525
+ */
1526
+ cascade?: boolean;
1527
+ /** Synonym of `cascade` — see above. */
1528
+ detach?: boolean;
1529
+ }
1530
+
1531
+ export type DeleteTagResult =
1532
+ | { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
1533
+ | { error: "tag_referenced_as_parent"; referencing_tags: string[] };
1534
+
1535
+ /**
1536
+ * Delete a tag: drop its identity row, untag every note carrying it, and
1537
+ * release any indexed fields it exclusively declared. Notes themselves are
1538
+ * NEVER deleted — only untagged.
1539
+ *
1540
+ * Referential integrity (vault#552): if another tag's `parent_names` array
1541
+ * names `name`, deleting it would silently orphan that reference — the
1542
+ * child tag's hierarchy edge would point at a name with no identity row
1543
+ * (the exact "renamed-away tag remains a live query surface" class of bug
1544
+ * the gardener found, one hop over from rename). Refuse by default with
1545
+ * `{ error: "tag_referenced_as_parent", referencing_tags }`; pass
1546
+ * `opts.cascade` or `opts.detach` (either — see {@link DeleteTagOpts}) to
1547
+ * proceed, stripping `name` from every referencing tag's `parent_names` in
1548
+ * the same transaction as the delete.
1549
+ */
1550
+ export function deleteTag(db: Database, name: string, opts?: DeleteTagOpts): DeleteTagResult {
1507
1551
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
1508
1552
  | { fields: string | null }
1509
1553
  | null;
1510
1554
  if (!row) return { deleted: false, notes_untagged: 0 };
1511
1555
 
1512
- const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
1513
- const notesUntagged = countRow.c;
1514
-
1515
- // Release any indexed fields this tag declared BEFORE the row drops.
1516
- // `releaseField` only drops the generated column + index when this tag is
1517
- // the last live declarer (co-declaration guard) a field co-declared by
1518
- // another live tag keeps its column and just loses this tag from the set.
1519
- // This lives in the store-level delete (not the MCP layer) so every caller
1520
- // — MCP delete-tag, the REST DELETE /tags/:name route, the import
1521
- // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
1522
- // bug report.
1523
- if (row.fields) {
1524
- try {
1525
- const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
1526
- for (const [fieldName, spec] of Object.entries(fields)) {
1527
- if (spec?.indexed === true) {
1528
- releaseField(db, fieldName, name);
1556
+ // Referential-integrity guard: who names `name` as a parent? Reuses the
1557
+ // SAME parent_names parsing loadTagHierarchy uses everywhere else in the
1558
+ // hierarchy (rather than a bespoke LIKE scan) so "who references this
1559
+ // tag" answers identically here as it does for query expansion.
1560
+ const hierarchy = loadTagHierarchy(db);
1561
+ const referencing = Array.from(hierarchy.childrenOf.get(name) ?? []);
1562
+ if (referencing.length > 0 && !opts?.cascade && !opts?.detach) {
1563
+ return { error: "tag_referenced_as_parent", referencing_tags: referencing.sort() };
1564
+ }
1565
+
1566
+ return transaction(db, (): DeleteTagResult => {
1567
+ // Strip the stale reference from every referencing tag's parent_names
1568
+ // BEFORE dropping this tag's own row — order doesn't matter for
1569
+ // correctness (different rows), but doing the repair first means a
1570
+ // mid-transaction failure never leaves the identity row gone with
1571
+ // dangling references still pointing at it.
1572
+ let parentRefsDetached = 0;
1573
+ if (referencing.length > 0) {
1574
+ const readStmt = db.prepare("SELECT parent_names FROM tags WHERE name = ?");
1575
+ const updateStmt = db.prepare("UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?");
1576
+ const now = new Date().toISOString();
1577
+ for (const refTag of referencing) {
1578
+ const r = readStmt.get(refTag) as { parent_names: string | null } | null;
1579
+ if (!r?.parent_names) continue;
1580
+ let parsed: unknown;
1581
+ try { parsed = JSON.parse(r.parent_names); } catch { continue; }
1582
+ if (!Array.isArray(parsed)) continue;
1583
+ const next = (parsed as unknown[]).filter((p) => p !== name);
1584
+ if (next.length === parsed.length) continue;
1585
+ updateStmt.run(next.length > 0 ? JSON.stringify(next) : null, now, refTag);
1586
+ parentRefsDetached++;
1587
+ }
1588
+ }
1589
+
1590
+ const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
1591
+ const notesUntagged = countRow.c;
1592
+
1593
+ // Release any indexed fields this tag declared BEFORE the row drops.
1594
+ // `releaseField` only drops the generated column + index when this tag is
1595
+ // the last live declarer (co-declaration guard) — a field co-declared by
1596
+ // another live tag keeps its column and just loses this tag from the set.
1597
+ // This lives in the store-level delete (not the MCP layer) so every caller
1598
+ // — MCP delete-tag, the REST DELETE /tags/:name route, the import
1599
+ // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
1600
+ // bug report.
1601
+ if (row.fields) {
1602
+ try {
1603
+ const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
1604
+ for (const [fieldName, spec] of Object.entries(fields)) {
1605
+ if (spec?.indexed === true) {
1606
+ releaseField(db, fieldName, name);
1607
+ }
1529
1608
  }
1609
+ } catch {
1610
+ // Malformed fields JSON — nothing to release; proceed with the delete.
1530
1611
  }
1531
- } catch {
1532
- // Malformed fields JSON — nothing to release; proceed with the delete.
1533
1612
  }
1534
- }
1535
1613
 
1536
- db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
1537
- db.prepare("DELETE FROM tags WHERE name = ?").run(name);
1614
+ db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
1615
+ db.prepare("DELETE FROM tags WHERE name = ?").run(name);
1538
1616
 
1539
- return { deleted: true, notes_untagged: notesUntagged };
1617
+ return {
1618
+ deleted: true,
1619
+ notes_untagged: notesUntagged,
1620
+ ...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
1621
+ };
1622
+ });
1540
1623
  }
1541
1624
 
1542
1625
  // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous: