@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.
@@ -1441,6 +1441,113 @@ describe("portable-md non-markdown round-trip (vault#328)", async () => {
1441
1441
  // No sidecar → no DB row.
1442
1442
  expect(stats.notes_created).toBe(0);
1443
1443
  });
1444
+
1445
+ // -------------------------------------------------------------------------
1446
+ // vault#552 — the write-time parent_names cycle guard must NOT abort an
1447
+ // import of an OLD export that carries a mutual-cycle fixture (accepted
1448
+ // before the guard shipped). The offending parent_names is dropped +
1449
+ // warned, the tags + notes still land. On a blow-away replace this is
1450
+ // load-bearing: the vault was already wiped, so an uncaught throw here
1451
+ // would leave it EMPTY with no rollback.
1452
+ // -------------------------------------------------------------------------
1453
+
1454
+ /** Hand-build a portable-md export whose two tag schemas form an A↔B
1455
+ * parent_names cycle, plus one note tagged with `a`. */
1456
+ function writeCyclicSchemaExport(dir: string): void {
1457
+ mkdirSync(join(dir, SIDECAR_DIR, "schemas"), { recursive: true });
1458
+ writeFileSync(
1459
+ join(dir, SIDECAR_DIR, "vault.yaml"),
1460
+ "export_format_version: 1\nexported_at: '2026-07-09T00:00:00.000Z'\n",
1461
+ );
1462
+ writeFileSync(
1463
+ join(dir, SIDECAR_DIR, "schemas", "a.yaml"),
1464
+ "name: a\ndescription: tag a\nparent_names:\n - b\n",
1465
+ );
1466
+ writeFileSync(
1467
+ join(dir, SIDECAR_DIR, "schemas", "b.yaml"),
1468
+ "name: b\ndescription: tag b\nparent_names:\n - a\n",
1469
+ );
1470
+ writeFileSync(
1471
+ join(dir, "note1.md"),
1472
+ "---\nid: note1\npath: note1\ntags:\n - a\ncreated_at: '2026-07-09T00:00:00.000Z'\nupdated_at: '2026-07-09T00:00:00.000Z'\n---\nhello from note1\n",
1473
+ );
1474
+ }
1475
+
1476
+ it("imports an export carrying a parent_names cycle — the cycle is dropped + warned, not fatal (vault#552 MUST-FIX 1)", async () => {
1477
+ const outDir = join(tmpBase, "cyclic-schemas");
1478
+ writeCyclicSchemaExport(outDir);
1479
+
1480
+ const restored = new SqliteStore(new Database(":memory:"));
1481
+ // MUST complete, not throw.
1482
+ const stats = await importPortableVault(restored, { inDir: outDir });
1483
+
1484
+ // Both schemas + the note landed.
1485
+ expect(stats.schemas_restored).toBe(2);
1486
+ expect(stats.notes_created).toBe(1);
1487
+ expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
1488
+ expect(await restored.getTagRecord("a")).not.toBeNull();
1489
+ expect(await restored.getTagRecord("b")).not.toBeNull();
1490
+
1491
+ // Exactly one of the two had its parent_names dropped (whichever the
1492
+ // filesystem walk restored SECOND closes the cycle) and is recorded.
1493
+ expect(stats.skipped_schema_parents).toHaveLength(1);
1494
+ const skipped = stats.skipped_schema_parents[0]!;
1495
+ expect(["a", "b"]).toContain(skipped.tag);
1496
+ expect(skipped.reason).toMatch(/cycle/i);
1497
+
1498
+ // The surviving hierarchy is acyclic: at most one of a/b still carries
1499
+ // parent_names (the other was dropped). Never both.
1500
+ const aParents = (await restored.getTagRecord("a"))!.parent_names ?? null;
1501
+ const bParents = (await restored.getTagRecord("b"))!.parent_names ?? null;
1502
+ expect(Boolean(aParents) && Boolean(bParents)).toBe(false);
1503
+ });
1504
+
1505
+ it("blow-away import of a cyclic-schema export leaves the vault populated, NOT empty (vault#552 MUST-FIX 1 — destructive path)", async () => {
1506
+ const outDir = join(tmpBase, "cyclic-blowaway");
1507
+ writeCyclicSchemaExport(outDir);
1508
+
1509
+ // Seed the target with a throwaway note so blow-away has something to
1510
+ // wipe — proving the wipe ran AND that the uncaught-throw regression
1511
+ // (wipe-then-abort → empty vault) is gone.
1512
+ const restored = new SqliteStore(new Database(":memory:"));
1513
+ await restored.createNote("doomed pre-existing note", { id: "old1", path: "old1" });
1514
+
1515
+ const stats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
1516
+
1517
+ expect(stats.notes_wiped).toBe(1); // the pre-existing note was wiped
1518
+ expect(await restored.getNote("old1")).toBeNull(); // gone
1519
+ // Vault is NOT left empty — the fixture note landed despite the cycle.
1520
+ expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
1521
+ expect(stats.skipped_schema_parents).toHaveLength(1);
1522
+ });
1523
+
1524
+ it("blow-away wipes a parent-before-child hierarchy with NO stale tag rows left (vault#552 MUST-FIX 2)", async () => {
1525
+ // The bug: blow-away's tag sweep called store.deleteTag(tag) with no
1526
+ // flag and ignored the return. listTagRecords is ORDER BY name, so the
1527
+ // namespace parent "task" is visited BEFORE its child "task/work" —
1528
+ // while the child's parent_names still references it — and deleteTag now
1529
+ // RETURNS {error:"tag_referenced_as_parent"} instead of throwing, so the
1530
+ // parent's row silently survived the "clean slate." Fixed by passing
1531
+ // {cascade:true} in the sweep.
1532
+ const target = new SqliteStore(new Database(":memory:"));
1533
+ await target.upsertTagRecord("task", { description: "parent" });
1534
+ await target.upsertTagRecord("task/work", { parent_names: ["task"], description: "child" });
1535
+ await target.createNote("stale note", { id: "stale1", path: "stale1", tags: ["task/work"] });
1536
+
1537
+ // Build a minimal UNRELATED export from a fresh store to replay.
1538
+ const sourceStore = new SqliteStore(new Database(":memory:"));
1539
+ await sourceStore.createNote("fresh body", { id: "fresh1", path: "fresh1", tags: ["fresh"] });
1540
+ const outDir = join(tmpBase, "blowaway-hierarchy-source");
1541
+ await exportVaultToDir(sourceStore, { outDir, exportedAt: "2026-07-09T00:00:00.000Z" });
1542
+
1543
+ await importPortableVault(target, { inDir: outDir, blowAway: true });
1544
+
1545
+ // After a full wipe, NEITHER old hierarchy tag row survives.
1546
+ const remaining = (await target.listTagRecords()).map((t) => t.tag);
1547
+ expect(remaining).not.toContain("task");
1548
+ expect(remaining).not.toContain("task/work");
1549
+ expect(await target.getNote("stale1")).toBeNull();
1550
+ });
1444
1551
  });
1445
1552
 
1446
1553
  // ---------------------------------------------------------------------------
@@ -76,6 +76,7 @@ import { readdirSync, readFileSync, realpathSync, statSync, mkdirSync, writeFile
76
76
  import { basename, join, relative, extname, dirname, resolve as resolvePath, sep as pathSep } from "path";
77
77
  import type { Store, Note, Link, Attachment } from "./types.js";
78
78
  import type { TagRecord } from "./tag-schemas.js";
79
+ import { ParentCycleError } from "./tag-schemas.js";
79
80
 
80
81
  // ---------------------------------------------------------------------------
81
82
  // Format constants
@@ -1631,6 +1632,17 @@ export interface ImportStats {
1631
1632
  * content file during the walk.
1632
1633
  */
1633
1634
  skipped_sidecars: Array<{ sidecar_id: string; expected_path: string | null; expected_extension: string | null; reason: string }>;
1635
+ /**
1636
+ * Tag schemas whose `parent_names` was DROPPED on restore because it
1637
+ * would have created a hierarchy cycle (vault#552 — the write-time cycle
1638
+ * guard in `upsertTagRecord`). The tag itself is still restored (its
1639
+ * description/fields/relationships land); only the offending
1640
+ * `parent_names` is skipped, so the import completes instead of aborting
1641
+ * mid-flight on an uncaught `ParentCycleError`. An OLD export that carried
1642
+ * a mutual-cycle fixture (accepted before the guard shipped) still
1643
+ * imports — the cycle is warned, not fatal. Empty in the common case.
1644
+ */
1645
+ skipped_schema_parents: Array<{ tag: string; parent_names: string[]; reason: string }>;
1634
1646
  /** Set when the caller passed `blowAway: true`; counts notes removed. */
1635
1647
  notes_wiped: number;
1636
1648
  /**
@@ -1802,6 +1814,7 @@ export async function importVault(
1802
1814
  skipped_links: [],
1803
1815
  skipped_attachments: [],
1804
1816
  skipped_sidecars: [],
1817
+ skipped_schema_parents: [],
1805
1818
  notes_wiped: 0,
1806
1819
  indexes_declared: 0,
1807
1820
  };
@@ -1821,10 +1834,18 @@ export async function importVault(
1821
1834
  }
1822
1835
  }
1823
1836
  // Clear tag rows too — `deleteNote` clears note_tags via FK cascade but
1824
- // leaves the `tags` table rows in place (orphaned schemas).
1837
+ // leaves the `tags` table rows in place (orphaned schemas). `cascade:
1838
+ // true` is REQUIRED here (vault#552): a blow-away is a full wipe, and
1839
+ // `listTagRecords` returns rows `ORDER BY name`, so a namespace parent
1840
+ // (`task`) sorts before its child (`task/work`) and is visited while the
1841
+ // child's `parent_names` still references it — without `cascade`,
1842
+ // `deleteTag` would RETURN `{error: "tag_referenced_as_parent"}` (it no
1843
+ // longer throws) and silently leave the parent's row behind, defeating
1844
+ // the "clean slate." Since we're deleting every tag anyway, cascading the
1845
+ // parent_names strip is exactly right.
1825
1846
  const tagRecords = await store.listTagRecords();
1826
1847
  for (const tag of tagRecords) {
1827
- await store.deleteTag(tag.tag);
1848
+ await store.deleteTag(tag.tag, { cascade: true });
1828
1849
  }
1829
1850
  }
1830
1851
 
@@ -1842,12 +1863,37 @@ export async function importVault(
1842
1863
  stats.schemas_restored++;
1843
1864
  continue;
1844
1865
  }
1845
- await store.upsertTagRecord(tagName, {
1866
+ const parentNames = (frontmatter.parent_names as string[] | null | undefined) ?? null;
1867
+ const patch = {
1846
1868
  description: (frontmatter.description as string | null | undefined) ?? null,
1847
1869
  fields: (frontmatter.fields as Record<string, unknown> | null | undefined) as any ?? null,
1848
1870
  relationships: (frontmatter.relationships as Record<string, unknown> | null | undefined) as any ?? null,
1849
- parent_names: (frontmatter.parent_names as string[] | null | undefined) ?? null,
1850
- });
1871
+ parent_names: parentNames,
1872
+ };
1873
+ try {
1874
+ await store.upsertTagRecord(tagName, patch);
1875
+ } catch (err) {
1876
+ // vault#552: the write-time cycle guard rejects a `parent_names` that
1877
+ // would close a hierarchy cycle. An OLD export carrying a mutual-cycle
1878
+ // fixture (accepted before the guard shipped) must still import —
1879
+ // dropping the offending `parent_names` and warning, not aborting the
1880
+ // whole import mid-flight (import isn't atomic; on a blow-away replace
1881
+ // the vault was already wiped in step 1, so an uncaught throw here
1882
+ // would leave it EMPTY). Retry WITHOUT parent_names so the tag's
1883
+ // description/fields/relationships still land; record a skip warning
1884
+ // mirroring the skipped_links/skipped_attachments/skipped_sidecars
1885
+ // precedent. Any OTHER error still propagates.
1886
+ if (err instanceof ParentCycleError) {
1887
+ stats.skipped_schema_parents.push({
1888
+ tag: tagName,
1889
+ parent_names: parentNames ?? [],
1890
+ reason: `parent_names would create a hierarchy cycle (${err.cycle.join(" → ")}); dropped on import`,
1891
+ });
1892
+ await store.upsertTagRecord(tagName, { ...patch, parent_names: null });
1893
+ } else {
1894
+ throw err;
1895
+ }
1896
+ }
1851
1897
  stats.schemas_restored++;
1852
1898
  }
1853
1899
 
package/core/src/store.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  type ConformanceReport,
36
36
  } from "./conformance.js";
37
37
  import type { SearchMode } from "./search-query.js";
38
+ import { runDoctorScan, type DoctorReport, type DoctorScanOpts } from "./doctor.js";
38
39
 
39
40
  /**
40
41
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -457,10 +458,15 @@ export class BunSqliteStore implements Store {
457
458
  return noteOps.listTags(this.db);
458
459
  }
459
460
 
460
- async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
461
- const result = noteOps.deleteTag(this.db, name);
461
+ async deleteTag(name: string, opts?: noteOps.DeleteTagOpts): Promise<noteOps.DeleteTagResult> {
462
+ const result = noteOps.deleteTag(this.db, name, opts);
463
+ // Referential-integrity refusal (vault#552) — nothing was written;
464
+ // caches and hooks are untouched.
465
+ if ("error" in result) return result;
462
466
  // The deleted tag may have been a parent or child in the hierarchy
463
- // and may have declared `fields` powering schema validation.
467
+ // and may have declared `fields` powering schema validation. A
468
+ // cascade/detach repair also rewrites OTHER tags' parent_names, so
469
+ // busting the hierarchy cache covers both cases.
464
470
  this._tagHierarchy = null;
465
471
  this._schemaConfig = null;
466
472
  // Fire "deleted" only when SOMETHING happened (the underlying
@@ -644,6 +650,14 @@ export class BunSqliteStore implements Store {
644
650
  return count;
645
651
  }
646
652
 
653
+ /**
654
+ * Read-only taxonomy/metadata integrity scan (vault#552). Pure read — no
655
+ * cache invalidation needed since nothing is written.
656
+ */
657
+ async doctor(opts?: DoctorScanOpts): Promise<DoctorReport> {
658
+ return runDoctorScan(this.db, opts);
659
+ }
660
+
647
661
  // ---- Tag Records (post-v14: full identity row) ----
648
662
 
649
663
  async listTagRecords() {
@@ -388,3 +388,69 @@ export function findHierarchyCycles(h: TagHierarchy): string[] {
388
388
  }
389
389
  return cycles;
390
390
  }
391
+
392
+ /**
393
+ * Cycle guard for a PROSPECTIVE `parent_names` write (vault#552). Setting
394
+ * `tag`'s parents to include `p` adds the edge `childrenOf[p] += tag` (tag
395
+ * becomes a child of `p`). That closes a cycle iff `tag` can ALREADY reach
396
+ * `p` via the existing hierarchy — i.e. `p` is already in `tag`'s descendant
397
+ * set — because the new edge would then let `p` reach back to itself through
398
+ * `tag`. Self-parent (`parentNames` containing `tag` itself) is caught by the
399
+ * same check: `getTagDescendants` always includes `tag` in its own result.
400
+ *
401
+ * Returns the offending cycle path (`[tag, ...existing chain down to p, p,
402
+ * tag]`, closing the loop) for the FIRST conflicting parent found, or `null`
403
+ * when no cycle would result. `h` must reflect the hierarchy BEFORE this
404
+ * write — the check only depends on OTHER tags' parent_names (edges pointing
405
+ * away from `tag`), never `tag`'s own current parent_names, so passing the
406
+ * pre-write hierarchy is always correct regardless of write ordering.
407
+ */
408
+ export function findParentCycle(
409
+ h: TagHierarchy,
410
+ tag: string,
411
+ parentNames: string[],
412
+ ): string[] | null {
413
+ const descendants = getTagDescendants(h, tag);
414
+ for (const p of parentNames) {
415
+ if (descendants.has(p)) {
416
+ return [...findChildPath(h, tag, p), tag];
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
422
+ /**
423
+ * BFS shortest path from `from` to `to` following `childrenOf` edges
424
+ * (parent → child). Used only to render a human-legible cycle path for
425
+ * {@link findParentCycle}'s error — the existence of a path is already
426
+ * established by the caller (`to` is a known member of `from`'s descendant
427
+ * set) before this runs, so the fallback return is unreachable in practice
428
+ * and exists only to keep the function total.
429
+ */
430
+ function findChildPath(h: TagHierarchy, from: string, to: string): string[] {
431
+ if (from === to) return [from];
432
+ const queue: string[] = [from];
433
+ const cameFrom = new Map<string, string>();
434
+ const visited = new Set<string>([from]);
435
+ while (queue.length > 0) {
436
+ const current = queue.shift()!;
437
+ const children = h.childrenOf.get(current);
438
+ if (!children) continue;
439
+ for (const child of children) {
440
+ if (visited.has(child)) continue;
441
+ visited.add(child);
442
+ cameFrom.set(child, current);
443
+ if (child === to) {
444
+ const path = [to];
445
+ let node = to;
446
+ while (cameFrom.has(node)) {
447
+ node = cameFrom.get(node)!;
448
+ path.unshift(node);
449
+ }
450
+ return path;
451
+ }
452
+ queue.push(child);
453
+ }
454
+ }
455
+ return [from, to];
456
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { Database } from "bun:sqlite";
19
19
  import { mapFieldType, validateFieldName } from "./indexed-fields.js";
20
+ import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // Types
@@ -132,10 +133,48 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
132
133
  return row ? rowToRecord(row) : null;
133
134
  }
134
135
 
136
+ /**
137
+ * Thrown by `upsertTagRecord` (and therefore by both `PUT /api/tags/:name`
138
+ * and the MCP `update-tag` tool — this is the single chokepoint both share,
139
+ * see `core/src/store.ts:upsertTagRecord`) when the incoming `parent_names`
140
+ * would create a cycle in the hierarchy (vault#552). Traversal elsewhere
141
+ * (`getTagDescendants`) is already cycle-safe — a visited-set stops it from
142
+ * looping forever — but the WRITE itself was dishonest about creating one:
143
+ * an adversarial or accidental `A→B` then `B→A` (or a bare self-parent)
144
+ * silently succeeded pre-#552. `cycle` is the offending path, e.g.
145
+ * `["A", "B", "A"]` for a direct A↔B cycle, or the longer chain for a
146
+ * transitive one; the SERVER layer scope-scrubs out-of-scope names in it
147
+ * for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
148
+ * same posture as `TagFieldConflictError`.
149
+ */
150
+ export class ParentCycleError extends Error {
151
+ code = "PARENT_CYCLE" as const;
152
+ error_type = "parent_cycle" as const;
153
+ tag: string;
154
+ cycle: string[];
155
+
156
+ constructor(tag: string, cycle: string[]) {
157
+ super(
158
+ `parent_cycle: setting "${tag}"'s parent_names would create a cycle: ${cycle.join(" → ")}`,
159
+ );
160
+ this.name = "ParentCycleError";
161
+ this.tag = tag;
162
+ this.cycle = cycle;
163
+ }
164
+ }
165
+
135
166
  /**
136
167
  * Upsert a tag record — partial update. Any field left `undefined` is
137
168
  * preserved. Pass `null` explicitly to clear a column. Always touches
138
169
  * `updated_at`; sets `created_at` on first insert.
170
+ *
171
+ * Cycle guard (vault#552): when `patch.parent_names` is a non-empty array,
172
+ * validate it against the CURRENT hierarchy (loaded fresh — this function is
173
+ * the shared chokepoint for both REST and MCP, so it can't rely on a
174
+ * possibly-stale caller cache) before touching any row. A conflicting parent
175
+ * throws {@link ParentCycleError} and nothing is persisted — same
176
+ * fail-closed-before-any-write posture as the cross-tag field-conflict
177
+ * checks in this module.
139
178
  */
140
179
  export function upsertTagRecord(
141
180
  db: Database,
@@ -147,6 +186,12 @@ export function upsertTagRecord(
147
186
  parent_names?: string[] | null;
148
187
  },
149
188
  ): TagRecord {
189
+ if (patch.parent_names != null && patch.parent_names.length > 0) {
190
+ const hierarchy = loadTagHierarchy(db);
191
+ const cycle = findParentCycle(hierarchy, tag, patch.parent_names);
192
+ if (cycle) throw new ParentCycleError(tag, cycle);
193
+ }
194
+
150
195
  const now = new Date().toISOString();
151
196
  db.prepare(
152
197
  "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
package/core/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type { SearchMode } from "./search-query.js";
6
6
  import type { ValidationStatus } from "./schema-defaults.js";
7
7
  import type { ConformanceReport } from "./conformance.js";
8
8
  import type { FindPathResult } from "./links.js";
9
+ import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
9
10
 
10
11
  // ---- Re-exports ----
11
12
 
@@ -14,6 +15,7 @@ export type { PrunedField } from "./indexed-fields.js";
14
15
  export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
15
16
  export type { ConformanceReport } from "./conformance.js";
16
17
  export type { FindPathResult } from "./links.js";
18
+ export type { DoctorReport, DoctorFinding, DoctorFindingType, DoctorSeverity, DoctorScanOpts } from "./doctor.js";
17
19
 
18
20
  // ---- Note ----
19
21
 
@@ -364,7 +366,20 @@ export interface Store {
364
366
  * alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
365
367
  */
366
368
  listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
367
- deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
369
+ /**
370
+ * Delete a tag. Refused (vault#552) when another tag's `parent_names`
371
+ * still references it — pass `cascade` or `detach` (synonyms; either
372
+ * strips the stale reference from the referencing tags' `parent_names`
373
+ * in the same transaction) to proceed anyway. Notes are never deleted,
374
+ * only untagged.
375
+ */
376
+ deleteTag(
377
+ name: string,
378
+ opts?: { cascade?: boolean; detach?: boolean },
379
+ ): Promise<
380
+ | { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
381
+ | { error: "tag_referenced_as_parent"; referencing_tags: string[] }
382
+ >;
368
383
  renameTag(
369
384
  oldName: string,
370
385
  newName: string,
@@ -431,6 +446,13 @@ export interface Store {
431
446
  */
432
447
  reconcileDeclaredIndexes(): Promise<number>;
433
448
 
449
+ /**
450
+ * Read-only taxonomy/metadata integrity scan (vault#552). Never mutates —
451
+ * every finding carries a suggested `remedy` the caller applies
452
+ * deliberately. See `core/src/doctor.ts` for the finding-type catalog.
453
+ */
454
+ doctor(opts?: DoctorScanOpts): Promise<DoctorReport>;
455
+
434
456
  // Tag records — full v14 identity row (description + fields + typed
435
457
  // relationships + parent_names + timestamps). See
436
458
  // docs/contracts/tag-data-model.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.4",
3
+ "version": "0.7.0-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/mcp-http.ts CHANGED
@@ -170,6 +170,8 @@ async function handleMcp(
170
170
  reason?: string;
171
171
  limit?: number;
172
172
  tag?: string;
173
+ cycle?: unknown;
174
+ referencing_tags?: unknown;
173
175
  };
174
176
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
175
177
  // values that are structurally invalid rather than merely "no
@@ -313,6 +315,17 @@ async function handleMcp(
313
315
  violations: e.violations ?? [],
314
316
  });
315
317
  }
318
+ // parent_names cycle guard (vault#552) — `update-tag`'s write would
319
+ // close a cycle. Mirrors REST's 409 `parent_cycle` shape; the tool
320
+ // wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
321
+ // for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
322
+ if (e?.code === "PARENT_CYCLE") {
323
+ throw new McpError(ErrorCode.InvalidRequest, message, {
324
+ error_type: "parent_cycle",
325
+ tag: e.tag,
326
+ cycle: e.cycle ?? [],
327
+ });
328
+ }
316
329
  // Generic catch-all (vault#554): any remaining error that carries a
317
330
  // stable `error_type` — either a domain error class that stamps it as
318
331
  // an instance property (IndexedFieldError, and the classes handled by
package/src/mcp-tools.ts CHANGED
@@ -23,11 +23,14 @@ import {
23
23
  filterHydratedLinksByTagScope,
24
24
  noteWithinTagScope,
25
25
  scrubIndexedFieldConflictError,
26
+ scrubParentCycleError,
27
+ scrubReferencingTagsByScope,
26
28
  scrubTagFieldViolationsByScope,
27
29
  tagsWithinScope,
28
30
  } from "./tag-scope.ts";
29
- import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
31
+ import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
30
32
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
33
+ import { runDoctorScan } from "../core/src/doctor.ts";
31
34
  import {
32
35
  findTokensReferencingTag,
33
36
  recordMcpMintLedger,
@@ -219,11 +222,12 @@ export function generateScopedMcpTools(
219
222
  }
220
223
 
221
224
  /**
222
- * Tag-delete and (future) tag-merge always check for tag-scoped tokens
223
- * referencing the doomed tag — regardless of whether the *deleter* is
224
- * itself tag-scoped. A successful delete that orphans an allowlist would
225
+ * Tag-delete and tag-merge always check for tag-scoped tokens referencing
226
+ * the doomed tag(s) — regardless of whether the CALLER is itself
227
+ * tag-scoped. A successful delete/merge that orphans an allowlist would
225
228
  * silently widen surface area downstream. Mirrors the REST 409
226
- * `tag_in_use_by_tokens` envelope.
229
+ * `tag_in_use_by_tokens` envelope (routes.ts's DELETE /tags/:name and
230
+ * POST /tags/merge run the identical check).
227
231
  */
228
232
  function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
229
233
  const store = getVaultStore(vaultName);
@@ -243,6 +247,29 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
243
247
  }
244
248
  return await orig(params);
245
249
  });
250
+ // vault#552: merging consumes every source tag (same identity-drop as
251
+ // delete), so a source referenced by a tag-scoped token would orphan that
252
+ // token's allowlist exactly like a bare delete would. Aggregate matches
253
+ // across all sources into one 409-equivalent envelope, same shape REST's
254
+ // POST /tags/merge returns.
255
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
256
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
257
+ const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
258
+ for (const src of sources) {
259
+ if (typeof src !== "string") continue;
260
+ const tokens = findTokensReferencingTag(store.db, src);
261
+ if (tokens.length > 0) referenced.push({ source: src, tokens });
262
+ }
263
+ if (referenced.length > 0) {
264
+ return {
265
+ error: "TagInUseByTokens",
266
+ error_type: "tag_in_use_by_tokens",
267
+ message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
268
+ referenced_by: referenced,
269
+ };
270
+ }
271
+ return await orig(params);
272
+ });
246
273
  }
247
274
 
248
275
  /**
@@ -535,6 +562,14 @@ function applyTagScopeWrappers(
535
562
  if (err instanceof IndexedFieldError) {
536
563
  throw scrubIndexedFieldConflictError(err, allowed);
537
564
  }
565
+ // parent_names cycle guard (vault#552) — the hierarchy `upsertTagRecord`
566
+ // validates against is vault-wide (scope-unaware by architecture), so
567
+ // the cycle path can name an out-of-scope tag. Same "write stays
568
+ // rejected, path gets generalized" posture as the field-conflict scrub
569
+ // above.
570
+ if (err instanceof ParentCycleError) {
571
+ throw scrubParentCycleError(err, allowed);
572
+ }
538
573
  throw err;
539
574
  }
540
575
  });
@@ -546,9 +581,59 @@ function applyTagScopeWrappers(
546
581
  if (typeof tag === "string" && !allowed.has(tag)) {
547
582
  return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
548
583
  }
584
+ const result = await orig(params);
585
+ // Referential-integrity refusal (vault#552) — scrub out-of-scope
586
+ // referencing tag names before returning. The delete stays refused
587
+ // either way; only the response's tag-name visibility changes.
588
+ if (result && typeof result === "object" && (result as any).error_type === "tag_referenced_as_parent") {
589
+ return {
590
+ ...(result as Record<string, unknown>),
591
+ referencing_tags: scrubReferencingTagsByScope((result as any).referencing_tags ?? [], allowed),
592
+ };
593
+ }
594
+ return result;
595
+ });
596
+
597
+ // rename-tag / merge-tags (vault#552 — MCP parity with the pre-existing
598
+ // REST engine). Same tag-scope posture as update-tag/delete-tag: every
599
+ // tag NAMED in the request (source(s) + target, or old/new name) must be
600
+ // inside the caller's allowlist — a rename/merge that pulls a tag out of
601
+ // scope (or in) is a privilege-boundary move, refuse the whole op before
602
+ // it reaches core.
603
+ wrapReadTool(tools, "rename-tag", async (orig, params) => {
604
+ const allowed = await getAllowed();
605
+ if (!allowed) return await orig(params);
606
+ const oldName = (params as any).old_name ?? (params as any).from ?? (params as any).tag;
607
+ const newName = (params as any).new_name ?? (params as any).to;
608
+ for (const t of [oldName, newName]) {
609
+ if (typeof t === "string" && !allowed.has(t)) {
610
+ return forbidden(`rename-tag: tag "${t}" is outside the token's allowlist`);
611
+ }
612
+ }
613
+ return await orig(params);
614
+ });
615
+
616
+ wrapReadTool(tools, "merge-tags", async (orig, params) => {
617
+ const allowed = await getAllowed();
618
+ if (!allowed) return await orig(params);
619
+ const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
620
+ const target = (params as any).target;
621
+ for (const t of [...sources, target]) {
622
+ if (typeof t === "string" && !allowed.has(t)) {
623
+ return forbidden(`merge-tags: tag "${t}" is outside the token's allowlist`);
624
+ }
625
+ }
549
626
  return await orig(params);
550
627
  });
551
628
 
629
+ // doctor (vault#552) — the scan itself is re-run with the caller's
630
+ // expanded allowlist rather than post-filtering the unscoped result, so
631
+ // aggregate counts (findings summary) never reflect out-of-scope data.
632
+ wrapReadTool(tools, "doctor", async (orig, params) => {
633
+ const allowed = await getAllowed();
634
+ if (!allowed) return await orig(params);
635
+ return runDoctorScan(store.db, { allowedTags: allowed });
636
+ });
552
637
  }
553
638
 
554
639
  function wrapReadTool(
@@ -518,6 +518,11 @@ function importResultFromStats(
518
518
  `Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
519
519
  );
520
520
  }
521
+ for (const sp of stats.skipped_schema_parents) {
522
+ warnings.push(
523
+ `Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
524
+ );
525
+ }
521
526
  const result: ImportResult = {
522
527
  notes_imported: stats.notes_created + stats.notes_updated,
523
528
  tags_imported: stats.schemas_restored,