@openparachute/vault 0.7.0-rc.4 → 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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `vault doctor` — read-only taxonomy/metadata integrity scan (vault#552).
3
+ *
4
+ * NOT to be confused with `parachute-vault doctor` (the CLI's install/daemon
5
+ * diagnostic in `src/cli.ts` — port reachability, config sanity, launchd/
6
+ * systemd registration). This module answers a different question: "did my
7
+ * tag reorg (rename/merge/delete) leak?" It never auto-fixes — every finding
8
+ * carries a suggested `remedy` the caller applies deliberately (usually via
9
+ * `rename-tag`/`merge-tags`/`prune-schema`/another `update-tag` call).
10
+ *
11
+ * Scans:
12
+ * - `dangling_parent_name` — a `parent_names` entry naming a tag with no
13
+ * identity row.
14
+ * - `parent_names_cycle` — a tag that reaches itself through its
15
+ * declared ancestor chain (traversal is cycle-safe; this just SURFACES
16
+ * data that predates the vault#552 write-time cycle guard, or was
17
+ * written by a path that bypasses it).
18
+ * - `mixed_type_indexed_field` — a note whose `metadata.<field>` JSON type
19
+ * disagrees with the field's declared indexed sqlite type. This is the
20
+ * WS4 typed-index "poison precursor": inconsistent column affinity
21
+ * coercion silently breaks ordering/comparison across rows. The finding
22
+ * shape here is intentionally reusable as WS4's migration pre-flight.
23
+ * - `orphaned_indexed_field_declarer` — an `indexed_fields` row naming a
24
+ * declarer tag with no `tags` row (overlaps `prune-schema`, which is the
25
+ * suggested remedy).
26
+ * - `dead_tag_metadata_reference` — HEURISTIC, always labeled `heuristic:
27
+ * true`. A metadata key is used elsewhere in the vault with values that
28
+ * ARE live tag names (establishing "this key holds tag-shaped values");
29
+ * a value under the same key that does NOT match any live tag is
30
+ * flagged as a possible stale reference to a renamed/merged/deleted tag
31
+ * — the PM's `metadata.epic` drift class from the #552 write-up. Vault
32
+ * has no history of past tag names, so this can never be certain —
33
+ * hence heuristic.
34
+ *
35
+ * Tag-scope (vault#552): pass `allowedTags` (the caller's EXPANDED
36
+ * allowlist, e.g. from `expandTokenTagScope`) to restrict every finding to
37
+ * in-scope tags/fields/notes. `null` (or omitted) — the default — scans the
38
+ * whole vault. The scan is re-run with the allowlist rather than filtering
39
+ * an unscoped result afterward, so the `summary` counts never leak
40
+ * out-of-scope activity.
41
+ */
42
+
43
+ import { Database } from "bun:sqlite";
44
+ import { loadTagHierarchy, findHierarchyCycles } from "./tag-hierarchy.js";
45
+ import { listIndexedFields } from "./indexed-fields.js";
46
+ import { pruneOrphanedIndexedFields } from "./indexed-fields.js";
47
+
48
+ export type DoctorFindingType =
49
+ | "dangling_parent_name"
50
+ | "parent_names_cycle"
51
+ | "mixed_type_indexed_field"
52
+ | "orphaned_indexed_field_declarer"
53
+ | "dead_tag_metadata_reference";
54
+
55
+ export type DoctorSeverity = "error" | "warning" | "info";
56
+
57
+ export interface DoctorFinding {
58
+ type: DoctorFindingType;
59
+ severity: DoctorSeverity;
60
+ /** The tag / field / metadata-key this finding is about. */
61
+ subject: string;
62
+ detail: string;
63
+ remedy: string;
64
+ /**
65
+ * True ONLY for `dead_tag_metadata_reference` — flags the finding as a
66
+ * best-effort inference (no tag-rename history exists to confirm it),
67
+ * never present (not even `false`) on the other, structurally-certain
68
+ * finding types.
69
+ */
70
+ heuristic?: true;
71
+ }
72
+
73
+ export interface DoctorReport {
74
+ findings: DoctorFinding[];
75
+ summary: string;
76
+ scanned_at: string;
77
+ }
78
+
79
+ export interface DoctorScanOpts {
80
+ /**
81
+ * The caller's expanded tag allowlist (root ∪ descendants). `null`
82
+ * (default) scans the whole vault. See the module doc comment for how
83
+ * each finding type is scoped.
84
+ */
85
+ allowedTags?: Set<string> | null;
86
+ }
87
+
88
+ /** Cap on exemplar note IDs embedded in a finding's `detail` (vault#552 — keep report bodies bounded). */
89
+ const MAX_EXEMPLARS = 5;
90
+
91
+ /**
92
+ * Build a per-note in-scope predicate for a tag-scoped doctor run (vault#552
93
+ * auth fold). A note is in-scope iff at least one of its tags is in
94
+ * `allowedTags`; an unscoped run (`allowedTags === null`) treats every note
95
+ * as in-scope. Caches each note's tag lookup so repeated checks across scans
96
+ * cost one query per note. Shared by the mixed-type-indexed-field and
97
+ * dead-tag-metadata scans, both of which query notes vault-wide and must NOT
98
+ * surface an out-of-scope note id (or count, or exemplar) to a scoped caller.
99
+ */
100
+ function makeNoteInScope(db: Database, allowedTags: Set<string> | null): (id: string) => boolean {
101
+ if (!allowedTags) return () => true;
102
+ const cache = new Map<string, boolean>();
103
+ return (id: string): boolean => {
104
+ const cached = cache.get(id);
105
+ if (cached !== undefined) return cached;
106
+ const tags = (db.prepare("SELECT tag_name FROM note_tags WHERE note_id = ?").all(id) as { tag_name: string }[]).map((r) => r.tag_name);
107
+ const inScope = tags.some((t) => allowedTags.has(t));
108
+ cache.set(id, inScope);
109
+ return inScope;
110
+ };
111
+ }
112
+
113
+ export function runDoctorScan(db: Database, opts?: DoctorScanOpts): DoctorReport {
114
+ const allowedTags = opts?.allowedTags ?? null;
115
+ const findings: DoctorFinding[] = [
116
+ ...scanDanglingParentNames(db, allowedTags),
117
+ ...scanParentNamesCycles(db, allowedTags),
118
+ ...scanMixedTypeIndexedFields(db, allowedTags),
119
+ ...scanOrphanedIndexedFieldDeclarers(db, allowedTags),
120
+ ...scanDeadTagMetadataReferences(db, allowedTags),
121
+ ];
122
+
123
+ const errors = findings.filter((f) => f.severity === "error").length;
124
+ const warnings = findings.filter((f) => f.severity === "warning").length;
125
+ const infos = findings.filter((f) => f.severity === "info").length;
126
+ const summary =
127
+ findings.length === 0
128
+ ? "clean — no integrity findings"
129
+ : `${findings.length} finding(s): ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`;
130
+
131
+ return { findings, summary, scanned_at: new Date().toISOString() };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // A. dangling parent_names
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function scanDanglingParentNames(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
139
+ const rows = db
140
+ .prepare("SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL")
141
+ .all() as { name: string; parent_names: string }[];
142
+ const allTags = new Set(
143
+ (db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
144
+ );
145
+
146
+ const findings: DoctorFinding[] = [];
147
+ for (const row of rows) {
148
+ if (allowedTags && !allowedTags.has(row.name)) continue;
149
+ let parsed: unknown;
150
+ try { parsed = JSON.parse(row.parent_names); } catch { continue; }
151
+ if (!Array.isArray(parsed)) continue;
152
+ for (const parent of parsed) {
153
+ if (typeof parent !== "string" || parent.length === 0) continue;
154
+ if (allTags.has(parent)) continue;
155
+ findings.push({
156
+ type: "dangling_parent_name",
157
+ severity: "warning",
158
+ subject: row.name,
159
+ detail: `tag "${row.name}" declares parent "${parent}", which has no identity row — a typo, a tag that was never created, or a rename/delete that didn't update this reference.`,
160
+ remedy: `remove "${parent}" from "${row.name}"'s parent_names (update-tag), or create the missing "${parent}" tag if it's meant to exist`,
161
+ });
162
+ }
163
+ }
164
+ return findings;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // B. parent_names cycles (pre-existing data — the write-time guard blocks
169
+ // NEW ones; this surfaces anything already on disk)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function scanParentNamesCycles(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
173
+ const hierarchy = loadTagHierarchy(db);
174
+ const cycles = findHierarchyCycles(hierarchy);
175
+ const findings: DoctorFinding[] = [];
176
+ for (const tag of cycles) {
177
+ if (allowedTags && !allowedTags.has(tag)) continue;
178
+ findings.push({
179
+ type: "parent_names_cycle",
180
+ severity: "error",
181
+ subject: tag,
182
+ detail: `tag "${tag}" reaches itself through its declared parent_names ancestor chain. Query expansion tolerates this (a visited-set stops it looping), but it's confusing hierarchy state.`,
183
+ remedy: `clear or correct parent_names on one of the tags in "${tag}"'s ancestor chain (update-tag) to break the cycle`,
184
+ });
185
+ }
186
+ return findings;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // C. mixed-type values in an indexed column (WS4 poison precursor)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** `json_type()` results consistent with each declared indexed sqlite storage class. */
194
+ const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
195
+ INTEGER: new Set(["integer", "real", "true", "false"]),
196
+ TEXT: new Set(["text"]),
197
+ };
198
+
199
+ function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
200
+ const findings: DoctorFinding[] = [];
201
+ const noteInScope = makeNoteInScope(db, allowedTags);
202
+ for (const f of listIndexedFields(db)) {
203
+ if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
204
+ const expected = EXPECTED_JSON_TYPES[f.sqliteType];
205
+ if (!expected) continue; // unknown sqlite_type — nothing to compare against
206
+
207
+ // `json_type(metadata, ?)` returns NULL when the key is absent — those
208
+ // notes simply don't declare the field and aren't a mismatch. Only rows
209
+ // where the key IS present get compared.
210
+ const path = `$."${f.field}"`;
211
+ const rows = db
212
+ .prepare(
213
+ `SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
214
+ )
215
+ .all(path) as { id: string; jt: string | null }[];
216
+ // Tag-scope (vault#552 auth fold): the note query above is vault-wide —
217
+ // filter mismatches to IN-SCOPE notes so a scoped caller never sees an
218
+ // out-of-scope note id (nor a count/exemplar reflecting one). If every
219
+ // mismatch is on an out-of-scope note, the finding is dropped entirely.
220
+ const mismatches = rows.filter((r) => r.jt !== null && !expected.has(r.jt) && noteInScope(r.id));
221
+ if (mismatches.length === 0) continue;
222
+
223
+ // Also generalize the "Declared by" list to in-scope declarers only —
224
+ // the field can be co-declared by an out-of-scope tag whose name must
225
+ // not leak (the caller sees this field only via its in-scope declarer).
226
+ const visibleDeclarers = allowedTags
227
+ ? f.declarerTags.filter((t) => allowedTags.has(t))
228
+ : f.declarerTags;
229
+ const exemplars = mismatches.slice(0, MAX_EXEMPLARS).map((r) => `${r.id} (${r.jt})`);
230
+ findings.push({
231
+ type: "mixed_type_indexed_field",
232
+ severity: "error",
233
+ subject: f.field,
234
+ detail: `field "${f.field}" is indexed as ${f.sqliteType} but ${mismatches.length} note(s) carry a metadata."${f.field}" value of a disagreeing JSON type — the generated column's affinity coercion makes ordering/filtering across these rows inconsistent. Declared by: ${visibleDeclarers.join(", ") || "(no live declarer)"}. Example note(s): ${exemplars.join(", ")}${mismatches.length > exemplars.length ? ", …" : ""}`,
235
+ remedy: `backfill each listed note's metadata."${f.field}" to a value matching the declared type, or relax the field's declared type (update-tag) if the mixed values are intentional`,
236
+ });
237
+ }
238
+ return findings;
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // D. orphaned indexed_fields declarers (overlaps prune-schema)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function scanOrphanedIndexedFieldDeclarers(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
246
+ const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
247
+ const findings: DoctorFinding[] = [];
248
+ for (const p of plan) {
249
+ if (allowedTags && !p.deadDeclarers.some((t) => allowedTags.has(t))) {
250
+ // Dead declarer names never survive `allowedTags` (it's derived from
251
+ // LIVE tags only) — this branch exists for completeness; in practice
252
+ // a scoped caller only sees this finding when the report is unscoped.
253
+ continue;
254
+ }
255
+ findings.push({
256
+ type: "orphaned_indexed_field_declarer",
257
+ severity: p.dropped ? "warning" : "info",
258
+ subject: p.field,
259
+ detail: p.dropped
260
+ ? `field "${p.field}" has NO surviving live declarer (dead: ${p.deadDeclarers.join(", ")}) — the generated column + index are stale and will be dropped on next prune (metadata values are never lost, only the index).`
261
+ : `field "${p.field}" has dead declarer tag(s) [${p.deadDeclarers.join(", ")}] still listed alongside live ones — cosmetic, no functional impact.`,
262
+ remedy: `run the prune-schema tool (apply: true) to drop the dead declarer(s)${p.dropped ? " and the now-unreferenced column/index" : ""}`,
263
+ });
264
+ }
265
+ return findings;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // E. metadata values equal to a tag name that no longer exists (heuristic)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ function scanDeadTagMetadataReferences(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
273
+ const liveTags = new Set(
274
+ (db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
275
+ );
276
+
277
+ const rows = db
278
+ .prepare("SELECT id, metadata FROM notes WHERE metadata IS NOT NULL AND metadata != ''")
279
+ .all() as { id: string; metadata: string }[];
280
+
281
+ // key -> value -> note ids carrying that (key, value) pair.
282
+ const byKey = new Map<string, Map<string, string[]>>();
283
+ for (const row of rows) {
284
+ let parsed: unknown;
285
+ try { parsed = JSON.parse(row.metadata); } catch { continue; }
286
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
287
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
288
+ if (typeof value !== "string" || value.length === 0) continue;
289
+ let valueMap = byKey.get(key);
290
+ if (!valueMap) { valueMap = new Map(); byKey.set(key, valueMap); }
291
+ let ids = valueMap.get(value);
292
+ if (!ids) { ids = []; valueMap.set(value, ids); }
293
+ ids.push(row.id);
294
+ }
295
+ }
296
+
297
+ // Tag-scope (vault#552 auth fold): a note is in-scope iff at least one of
298
+ // its tags is in `allowedTags` (unscoped → every note in-scope).
299
+ const noteInScope = makeNoteInScope(db, allowedTags);
300
+
301
+ const findings: DoctorFinding[] = [];
302
+ for (const [key, valueMap] of byKey) {
303
+ // Signal: does this metadata key hold tag-shaped values on IN-SCOPE
304
+ // notes? Computed over in-scope notes ONLY (not vault-wide) so the
305
+ // example live value quoted in `detail` can never leak an out-of-scope
306
+ // tag name — a scoped caller must not learn a tag exists just because a
307
+ // note it can't see uses it as a metadata value. Only flag mismatches
308
+ // under a key that demonstrably holds tag-shaped values in-scope.
309
+ const liveValuesInScope = [...valueMap.entries()]
310
+ .filter(([v, ids]) => liveTags.has(v) && ids.some(noteInScope))
311
+ .map(([v]) => v);
312
+ if (liveValuesInScope.length === 0) continue;
313
+
314
+ for (const [value, noteIds] of valueMap) {
315
+ if (liveTags.has(value)) continue;
316
+ const inScopeIds = noteIds.filter(noteInScope);
317
+ if (inScopeIds.length === 0) continue;
318
+ const exemplars = inScopeIds.slice(0, MAX_EXEMPLARS);
319
+ findings.push({
320
+ type: "dead_tag_metadata_reference",
321
+ severity: "info",
322
+ subject: `metadata.${key}`,
323
+ detail: `${inScopeIds.length} note(s) carry metadata.${key} = "${value}", which matches no current tag — other notes' metadata.${key} values ARE live tags (e.g. "${liveValuesInScope[0]}"), suggesting "${value}" may be a stale reference to a tag that was renamed, merged, or deleted. Example note(s): ${exemplars.join(", ")}${inScopeIds.length > exemplars.length ? ", …" : ""}`,
324
+ remedy: `if "${value}" was renamed/merged/deleted, update these notes' metadata.${key} to the current tag name; if "${value}" was never a tag, ignore this finding`,
325
+ heuristic: true,
326
+ });
327
+ }
328
+ }
329
+ return findings;
330
+ }
package/core/src/mcp.ts CHANGED
@@ -190,9 +190,11 @@ export interface GenerateMcpToolsOpts {
190
190
  }
191
191
 
192
192
  /**
193
- * Generate the consolidated MCP tools for a vault. Surface (10):
193
+ * Generate the consolidated MCP tools for a vault. Surface (13):
194
194
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
195
- * 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.
196
198
  */
197
199
  export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
198
200
  const db: Database = store.db;
@@ -1716,11 +1718,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1716
1718
  // mgmt + future config writes; deletes are write-tier mutations.
1717
1719
  // See delete-note rationale.
1718
1720
  requiredVerb: "write",
1719
- 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.",
1720
1722
  inputSchema: {
1721
1723
  type: "object",
1722
1724
  properties: {
1723
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)." },
1724
1728
  },
1725
1729
  required: ["tag"],
1726
1730
  },
@@ -1731,8 +1735,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1731
1735
  // Indexed-field release is handled inside store.deleteTag →
1732
1736
  // noteOps.deleteTag so every entry point (MCP, REST, import sweep)
1733
1737
  // releases consistently with the co-declaration guard. See the
1734
- // gitcoin orphaned-fields bug report.
1735
- 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);
1736
1839
  },
1737
1840
  },
1738
1841
 
@@ -1822,6 +1925,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1822
1925
  },
1823
1926
  },
1824
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
+
1825
1944
  ];
1826
1945
  }
1827
1946
 
package/core/src/notes.ts CHANGED
@@ -1512,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
1512
1512
  return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
1513
1513
  }
1514
1514
 
1515
- 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 {
1516
1551
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
1517
1552
  | { fields: string | null }
1518
1553
  | null;
1519
1554
  if (!row) return { deleted: false, notes_untagged: 0 };
1520
1555
 
1521
- const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
1522
- const notesUntagged = countRow.c;
1523
-
1524
- // Release any indexed fields this tag declared BEFORE the row drops.
1525
- // `releaseField` only drops the generated column + index when this tag is
1526
- // the last live declarer (co-declaration guard) a field co-declared by
1527
- // another live tag keeps its column and just loses this tag from the set.
1528
- // This lives in the store-level delete (not the MCP layer) so every caller
1529
- // — MCP delete-tag, the REST DELETE /tags/:name route, the import
1530
- // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
1531
- // bug report.
1532
- if (row.fields) {
1533
- try {
1534
- const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
1535
- for (const [fieldName, spec] of Object.entries(fields)) {
1536
- if (spec?.indexed === true) {
1537
- 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
+ }
1538
1608
  }
1609
+ } catch {
1610
+ // Malformed fields JSON — nothing to release; proceed with the delete.
1539
1611
  }
1540
- } catch {
1541
- // Malformed fields JSON — nothing to release; proceed with the delete.
1542
1612
  }
1543
- }
1544
1613
 
1545
- db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
1546
- 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);
1547
1616
 
1548
- return { deleted: true, notes_untagged: notesUntagged };
1617
+ return {
1618
+ deleted: true,
1619
+ notes_untagged: notesUntagged,
1620
+ ...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
1621
+ };
1622
+ });
1549
1623
  }
1550
1624
 
1551
1625
  // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous: