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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { Database } from "bun:sqlite";
19
+ import { mapFieldType, validateFieldName } from "./indexed-fields.js";
20
+ import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
19
21
 
20
22
  // ---------------------------------------------------------------------------
21
23
  // Types
@@ -131,10 +133,48 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
131
133
  return row ? rowToRecord(row) : null;
132
134
  }
133
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
+
134
166
  /**
135
167
  * Upsert a tag record — partial update. Any field left `undefined` is
136
168
  * preserved. Pass `null` explicitly to clear a column. Always touches
137
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.
138
178
  */
139
179
  export function upsertTagRecord(
140
180
  db: Database,
@@ -146,6 +186,12 @@ export function upsertTagRecord(
146
186
  parent_names?: string[] | null;
147
187
  },
148
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
+
149
195
  const now = new Date().toISOString();
150
196
  db.prepare(
151
197
  "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
@@ -280,17 +326,23 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
280
326
  * time"); dropping the inner-shape gate is consistent with that intent.
281
327
  */
282
328
  export function validateRelationships(raw: unknown): Record<string, unknown> {
329
+ // `error_type` (vault#554) is attached directly on the thrown Error
330
+ // (duck-typed extra property, same pattern as QueryError's optional
331
+ // fields) rather than via a dedicated class — this is a validation
332
+ // leaf with one caller-facing shape, not a reusable domain error. Both
333
+ // REST (src/routes.ts, which already hardcodes this same string) and the
334
+ // generic MCP domain-error mapping (src/mcp-http.ts) key off it.
283
335
  if (raw === null || raw === undefined) {
284
- throw new Error("relationships: expected an object, got null/undefined");
336
+ throw relationshipsError("relationships: expected an object, got null/undefined");
285
337
  }
286
338
  if (typeof raw !== "object" || Array.isArray(raw)) {
287
- throw new Error(
339
+ throw relationshipsError(
288
340
  "relationships: expected an object mapping relationship name → value (got an array or primitive)",
289
341
  );
290
342
  }
291
343
  for (const rel of Object.keys(raw as Record<string, unknown>)) {
292
344
  if (!rel) {
293
- throw new Error("relationships: keys must be non-empty strings");
345
+ throw relationshipsError("relationships: keys must be non-empty strings");
294
346
  }
295
347
  }
296
348
  // Round-trip through JSON to (a) confirm the payload is serializable —
@@ -300,11 +352,15 @@ export function validateRelationships(raw: unknown): Record<string, unknown> {
300
352
  try {
301
353
  serialized = JSON.stringify(raw);
302
354
  } catch (err) {
303
- throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
355
+ throw relationshipsError(`relationships: value must be JSON-serializable (${(err as Error).message})`);
304
356
  }
305
357
  return JSON.parse(serialized) as Record<string, unknown>;
306
358
  }
307
359
 
360
+ function relationshipsError(message: string): Error {
361
+ return Object.assign(new Error(message), { error_type: "invalid_relationships" as const });
362
+ }
363
+
308
364
  // ---------------------------------------------------------------------------
309
365
  // Helpers
310
366
  // ---------------------------------------------------------------------------
@@ -334,3 +390,174 @@ function jsonOrNull(value: unknown): string | null {
334
390
  }
335
391
  return JSON.stringify(value);
336
392
  }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Cross-tag field validation (vault#553 / #554) — update-tag messaging
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * A single field-declaration violation surfaced by {@link collectTagFieldViolations}.
400
+ * `reason` is a stable machine-checkable slug; `message` is the existing
401
+ * human-readable prose (unchanged wording, just no longer thrown on the
402
+ * first hit).
403
+ */
404
+ export interface TagFieldViolation {
405
+ field: string;
406
+ reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
407
+ message: string;
408
+ /**
409
+ * The conflicting declarer tag — present on the cross-tag reasons
410
+ * (`type_conflict` / `indexed_flag_conflict`) only; the solo own-field
411
+ * reasons have no other party. Core populates it unconditionally
412
+ * (scope-unaware by architecture); the SERVER layers scrub it — and
413
+ * generalize `message` — when the declarer is outside a tag-scoped
414
+ * caller's allowlist (`scrubTagFieldViolationsByScope` in
415
+ * src/tag-scope.ts), so a scoped session learns THAT a conflict exists
416
+ * (the write is still rejected — integrity is scope-independent) but not
417
+ * WHICH out-of-scope tag it conflicts with or what that tag declares.
418
+ */
419
+ other_tag?: string;
420
+ }
421
+
422
+ /**
423
+ * Thrown by `update-tag` (MCP tool + REST `PUT /api/tags/:name`) when one or
424
+ * more fields in the incoming `fields` payload conflict with another tag's
425
+ * declaration, or declare an unindexable/invalid indexed field. Carries
426
+ * EVERY violation found in the call (vault#553 settled complaint: the prior
427
+ * behavior threw on the FIRST offending field and silently never reported
428
+ * the rest — two testers independently assumed the other fields had landed).
429
+ * The message states explicitly that no changes were applied — the whole
430
+ * `upsertTagRecord` call is validated BEFORE any write, so a rejection here
431
+ * always leaves the tag record untouched (atomicity unchanged from before;
432
+ * this only improves what the caller is told).
433
+ */
434
+ export class TagFieldConflictError extends Error {
435
+ code = "TAG_FIELD_CONFLICT" as const;
436
+ error_type = "tag_field_conflict" as const;
437
+ tag: string;
438
+ violations: TagFieldViolation[];
439
+
440
+ constructor(tag: string, violations: TagFieldViolation[]) {
441
+ const summary = violations.map((v) => `${v.field} (${v.reason}): ${v.message}`).join("; ");
442
+ super(
443
+ `tag_field_conflict: ${violations.length} field violation(s) for tag "${tag}" — no changes were applied. ${summary}`,
444
+ );
445
+ this.name = "TagFieldConflictError";
446
+ this.tag = tag;
447
+ this.violations = violations;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Validate the fields a caller is declaring/redeclaring for `tag` against
453
+ * every OTHER tag's schema — `type` and `indexed` must agree across all
454
+ * declarers (both are global per field). Returns every violation found —
455
+ * never throws — so the caller can report the complete set in one
456
+ * structured error (or none, when the array is empty).
457
+ *
458
+ * `incomingFields` is the set of fields THIS call is declaring (MCP:
459
+ * `params.fields`; REST: `body.fields`) — not the full merged field map.
460
+ * Fields the call doesn't touch were already valid (or already accepted)
461
+ * and are not re-validated here, matching the pre-#553 scope of the check.
462
+ *
463
+ * Two deliberate exclusions preserve pre-existing wire contracts
464
+ * (vault#554 — statuses/error_types on previously-working calls must not
465
+ * change):
466
+ *
467
+ * 1. The SEPARATE own-field checks (unsupported type for indexing,
468
+ * invalid identifier) are NOT included — REST's `PUT /api/tags/:name`
469
+ * has an established, tested single-violation `IndexedFieldError` →
470
+ * 400 `invalid_indexed_field` contract for those (vault#478). Use
471
+ * {@link collectTagFieldViolations} (own-field checks included) for a
472
+ * caller — MCP's `update-tag` tool — with no such prior contract.
473
+ * 2. The type-conflict check is SKIPPED for any field whose INCOMING spec
474
+ * is `indexed: true` (wire review, vault#554): a both-indexed cross-tag
475
+ * type conflict already errors on main via `store.upsertTagRecord` →
476
+ * `declareField`'s cross-declarer sqlite-type check (`IndexedFieldError`
477
+ * → 400 `invalid_indexed_field`), and reporting it here would flip that
478
+ * established 400 to a 422. Letting it fall through preserves the 400.
479
+ * No case is silently re-accepted by the skip: an incoming-indexed type
480
+ * conflict against an INDEXED declarer hits declareField's 400; against
481
+ * a NON-indexed declarer the indexed-FLAG conflict below still fires
482
+ * (the flags necessarily differ). What this function newly reports —
483
+ * both silent 200s on main — is (a) type conflicts on NON-indexed
484
+ * incoming fields and (b) indexed-flag conflicts.
485
+ */
486
+ export function collectCrossTagFieldViolations(
487
+ db: Database,
488
+ tag: string,
489
+ incomingFields: Record<string, TagFieldSchema>,
490
+ ): TagFieldViolation[] {
491
+ const violations: TagFieldViolation[] = [];
492
+ const otherSchemas = listTagSchemas(db).filter((s) => s.tag !== tag);
493
+
494
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
495
+ const incomingIndexed = spec.indexed === true;
496
+ for (const other of otherSchemas) {
497
+ const otherSpec = other.fields?.[fieldName];
498
+ if (!otherSpec) continue;
499
+ // See doc comment exclusion 2: incoming-indexed type conflicts keep
500
+ // their pre-existing declareField → 400 invalid_indexed_field path.
501
+ if (!incomingIndexed && otherSpec.type !== spec.type) {
502
+ violations.push({
503
+ field: fieldName,
504
+ reason: "type_conflict",
505
+ message: `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
506
+ other_tag: other.tag,
507
+ });
508
+ }
509
+ if ((otherSpec.indexed === true) !== incomingIndexed) {
510
+ violations.push({
511
+ field: fieldName,
512
+ reason: "indexed_flag_conflict",
513
+ message: `field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
514
+ other_tag: other.tag,
515
+ });
516
+ }
517
+ }
518
+ }
519
+ return violations;
520
+ }
521
+
522
+ /**
523
+ * Full field-violation collection: {@link collectCrossTagFieldViolations}
524
+ * PLUS the own-field checks (unsupported type for indexing, invalid
525
+ * identifier). Used by the MCP `update-tag` tool, which — unlike REST — had
526
+ * no prior single-violation status-code contract to preserve for those two
527
+ * checks (its old inline loop threw an unstructured `Error` for them, same
528
+ * as everything else pre-#554); collecting everything here is a strict
529
+ * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
530
+ * why REST calls that narrower function directly instead of this one.
531
+ */
532
+ export function collectTagFieldViolations(
533
+ db: Database,
534
+ tag: string,
535
+ incomingFields: Record<string, TagFieldSchema>,
536
+ ): TagFieldViolation[] {
537
+ const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
538
+
539
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
540
+ if (spec.indexed === true) {
541
+ const mapped = mapFieldType(spec.type);
542
+ if (!mapped) {
543
+ violations.push({
544
+ field: fieldName,
545
+ reason: "unsupported_indexed_type",
546
+ message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
547
+ });
548
+ } else {
549
+ try {
550
+ validateFieldName(fieldName);
551
+ } catch (err) {
552
+ violations.push({
553
+ field: fieldName,
554
+ reason: "invalid_field_name",
555
+ message: (err as Error).message,
556
+ });
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ return violations;
563
+ }
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.3",
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",