@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.
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/mcp.ts +124 -5
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +45 -0
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +74 -1
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +25 -8
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/core/src/portable-md.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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<
|
|
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
|
+
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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
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
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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(
|
package/src/mirror-import.ts
CHANGED
|
@@ -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,
|