@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/src/mcp.ts CHANGED
@@ -190,9 +190,11 @@ export interface GenerateMcpToolsOpts {
190
190
  }
191
191
 
192
192
  /**
193
- * Generate the consolidated MCP tools for a vault. Surface (10):
193
+ * Generate the consolidated MCP tools for a vault. Surface (13):
194
194
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
195
- * delete-tag, find-path, vault-info, prune-schema (admin).
195
+ * delete-tag, rename-tag, merge-tags, find-path, vault-info, prune-schema
196
+ * (admin), doctor (admin). `manage-token` (admin) is appended by the SERVER
197
+ * layer (src/mcp-tools.ts), not here — see that file's doc comment.
196
198
  */
197
199
  export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
198
200
  const db: Database = store.db;
@@ -1586,15 +1588,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1586
1588
  description: { type: "string", description: "Human-readable description of what this tag means" },
1587
1589
  fields: {
1588
1590
  type: "object",
1589
- description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
1591
+ description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true, "default": "active" } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299). Mark a field `indexed: true` to make it queryable — an indexed field\'s TYPE is ALWAYS enforced (a type-mismatched write is REJECTED, independent of `strict`) because a bad-typed value silently poisons range-query ordering (vault#553).',
1590
1592
  additionalProperties: {
1591
1593
  type: "object",
1592
1594
  properties: {
1593
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
1595
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1594
1596
  description: { type: "string" },
1595
- enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
1596
- indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
1597
- strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off." },
1597
+ enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
1598
+ default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
1599
+ indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean are indexable. Indexed a type-mismatched write is HARD-REJECTED (schema_validation), not just warned vault#553." },
1600
+ strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
1598
1601
  required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
1599
1602
  cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
1600
1603
  },
@@ -1716,11 +1719,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1716
1719
  // mgmt + future config writes; deletes are write-tier mutations.
1717
1720
  // See delete-note rationale.
1718
1721
  requiredVerb: "write",
1719
- description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
1722
+ description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
1720
1723
  inputSchema: {
1721
1724
  type: "object",
1722
1725
  properties: {
1723
1726
  tag: { type: "string", description: "Tag name to delete" },
1727
+ cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
1728
+ detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
1724
1729
  },
1725
1730
  required: ["tag"],
1726
1731
  },
@@ -1731,8 +1736,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1731
1736
  // Indexed-field release is handled inside store.deleteTag →
1732
1737
  // noteOps.deleteTag so every entry point (MCP, REST, import sweep)
1733
1738
  // releases consistently with the co-declaration guard. See the
1734
- // gitcoin orphaned-fields bug report.
1735
- return await store.deleteTag(tag);
1739
+ // gitcoin orphaned-fields bug report. The referential-integrity
1740
+ // guard (vault#552) is ALSO inside store.deleteTag, so it returns
1741
+ // (not throws) `{error: "tag_referenced_as_parent", ...}` when
1742
+ // refused — same in-band shape delete-tag has always used for its
1743
+ // token-reference guard (applyTagDependencyGuards, src/mcp-tools.ts).
1744
+ return await store.deleteTag(tag, {
1745
+ cascade: params.cascade === true,
1746
+ detach: params.detach === true,
1747
+ });
1748
+ },
1749
+ },
1750
+
1751
+ // =====================================================================
1752
+ // 7a. rename-tag — atomic cascading rename (vault#552 MCP parity)
1753
+ // =====================================================================
1754
+ {
1755
+ name: "rename-tag",
1756
+ requiredVerb: "write",
1757
+ description:
1758
+ "Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
1759
+ inputSchema: {
1760
+ type: "object",
1761
+ properties: {
1762
+ old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
1763
+ new_name: { type: "string", description: "The new name. Alias: to." },
1764
+ from: { type: "string", description: "Alias for old_name." },
1765
+ to: { type: "string", description: "Alias for new_name." },
1766
+ tag: { type: "string", description: "Alias for old_name." },
1767
+ },
1768
+ },
1769
+ execute: async (params) => {
1770
+ const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
1771
+ const newName = (params.new_name ?? params.to) as string | undefined;
1772
+ if (typeof oldName !== "string" || oldName.length === 0) {
1773
+ throw structuredError("rename-tag: old_name (or from/tag) is required", {
1774
+ error_type: "invalid_request",
1775
+ field: "old_name",
1776
+ });
1777
+ }
1778
+ if (typeof newName !== "string" || newName.length === 0) {
1779
+ throw structuredError("rename-tag: new_name (or to) is required", {
1780
+ error_type: "invalid_request",
1781
+ field: "new_name",
1782
+ });
1783
+ }
1784
+ const result = await store.renameTag(oldName, newName);
1785
+ if ("error" in result) {
1786
+ if (result.error === "not_found") {
1787
+ throw structuredError(`rename-tag: tag "${oldName}" not found`, {
1788
+ error_type: "tag_not_found",
1789
+ field: "old_name",
1790
+ });
1791
+ }
1792
+ throw Object.assign(
1793
+ new Error(
1794
+ `rename-tag: target "${newName}" (or one of its sub-tags) already exists — use merge-tags to combine them instead`,
1795
+ ),
1796
+ {
1797
+ error_type: "target_exists" as const,
1798
+ target: newName,
1799
+ conflicting: result.conflicting,
1800
+ hint: "use merge-tags to combine the tags instead",
1801
+ },
1802
+ );
1803
+ }
1804
+ return result;
1805
+ },
1806
+ },
1807
+
1808
+ // =====================================================================
1809
+ // 7b. merge-tags — same machinery, N sources → one target
1810
+ // =====================================================================
1811
+ {
1812
+ name: "merge-tags",
1813
+ requiredVerb: "write",
1814
+ description:
1815
+ "Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
1816
+ inputSchema: {
1817
+ type: "object",
1818
+ properties: {
1819
+ sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
1820
+ target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
1821
+ },
1822
+ required: ["sources", "target"],
1823
+ },
1824
+ execute: async (params) => {
1825
+ const sources = params.sources;
1826
+ const target = params.target;
1827
+ if (!Array.isArray(sources) || sources.length === 0 || !sources.every((s) => typeof s === "string" && s.length > 0)) {
1828
+ throw structuredError("merge-tags: sources must be a non-empty array of strings", {
1829
+ error_type: "invalid_request",
1830
+ field: "sources",
1831
+ });
1832
+ }
1833
+ if (typeof target !== "string" || target.length === 0) {
1834
+ throw structuredError("merge-tags: target must be a non-empty string", {
1835
+ error_type: "invalid_request",
1836
+ field: "target",
1837
+ });
1838
+ }
1839
+ return await store.mergeTags(sources as string[], target);
1736
1840
  },
1737
1841
  },
1738
1842
 
@@ -1822,6 +1926,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1822
1926
  },
1823
1927
  },
1824
1928
 
1929
+ // =====================================================================
1930
+ // 11. doctor — read-only taxonomy/metadata integrity scan (vault#552)
1931
+ // =====================================================================
1932
+ {
1933
+ name: "doctor",
1934
+ // `admin` — same tier as prune-schema: a diagnostic over the WHOLE
1935
+ // vault's taxonomy, not scoped to any one tag's write authority.
1936
+ requiredVerb: "admin",
1937
+ description:
1938
+ "Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Reports, per finding, {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
1939
+ inputSchema: { type: "object", properties: {} },
1940
+ execute: async () => {
1941
+ return await store.doctor();
1942
+ },
1943
+ },
1944
+
1825
1945
  ];
1826
1946
  }
1827
1947
 
@@ -1834,18 +1954,33 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1834
1954
  * for any field they omitted. Returns the IDs of the notes whose metadata was
1835
1955
  * actually written — callers use this to re-read ONLY the mutated notes (and
1836
1956
  * to skip the re-read entirely when nothing changed). The common no-schema /
1837
- * no-defaults path returns an empty array.
1957
+ * no-declared-defaults path returns an empty array.
1958
+ *
1959
+ * vault#553 Decision B: backfill is EXPLICIT-`default`-only. A field with no
1960
+ * declared `default` is skipped entirely here — it stays genuinely absent on
1961
+ * the note, not silently filled with the first enum value or a type
1962
+ * zero-value (the pre-0.7.0 behavior, which made "never set" and
1963
+ * "explicitly set to the default" indistinguishable and broke `exists:false`).
1964
+ * Exported so `src/routes.ts` (REST create/PATCH) shares this ONE
1965
+ * implementation instead of carrying its own copy — the two had drifted into
1966
+ * a byte-identical duplicate pre-#553; centralizing here means the cloud
1967
+ * runtime (which imports core directly, not `src/routes.ts`) automatically
1968
+ * inherits this behavior with zero handler-side work.
1838
1969
  *
1839
1970
  * vault#299: this runs AFTER the create write (so AFTER the strict gate) and
1840
1971
  * intentionally does NOT re-run `enforceStrict`. Defaults are always
1841
- * conforming by construction — `defaultForField` returns the first enum value
1842
- * / the type's zero-value, so a default can never violate type/enum. And a
1843
- * `required` strict field is already caught at the pre-write gate, so a note
1844
- * that would need a default to satisfy `required` never reaches this filler
1845
- * (the create was rejected first). Don't add a defaults path that could
1846
- * inject a violating value without re-gating.
1972
+ * conforming by construction — `store.upsertTagRecord` validates a field's
1973
+ * `default` against its own `type`/`enum` BEFORE the schema can be persisted
1974
+ * (`InvalidFieldDefaultError` / `TagFieldConflictError` reason
1975
+ * `invalid_default`), so a default can never violate type/enum at read time
1976
+ * here. And a `required` strict field is already caught at the pre-write
1977
+ * gate, so a note that would need a default to satisfy `required` never
1978
+ * reaches this filler (the create was rejected first) — declaring a
1979
+ * `default` does NOT satisfy `required`; the caller must still set the field
1980
+ * explicitly. Don't add a defaults path that could inject a violating value
1981
+ * without re-gating.
1847
1982
  */
1848
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1983
+ export async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1849
1984
  const schemas = tagSchemaOps.getTagSchemaMap(db);
1850
1985
  if (Object.keys(schemas).length === 0) return [];
1851
1986
 
@@ -1854,9 +1989,10 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1854
1989
  const schema = schemas[tag];
1855
1990
  if (!schema?.fields) continue;
1856
1991
  for (const [field, fieldSchema] of Object.entries(schema.fields)) {
1857
- if (!(field in defaults)) {
1858
- defaults[field] = defaultForField(fieldSchema);
1859
- }
1992
+ if (field in defaults) continue; // first tag that declares a REAL default wins
1993
+ const value = defaultForField(fieldSchema);
1994
+ if (value === undefined) continue; // no `default` declared — leave the slot open for a later tag
1995
+ defaults[field] = value;
1860
1996
  }
1861
1997
  }
1862
1998
  if (Object.keys(defaults).length === 0) return [];
@@ -1882,13 +2018,13 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1882
2018
  return mutated;
1883
2019
  }
1884
2020
 
1885
- function defaultForField(field: { type: string; enum?: string[] }): unknown {
1886
- if (field.enum && field.enum.length > 0) return field.enum[0];
1887
- switch (field.type) {
1888
- case "boolean": return false;
1889
- case "integer": return 0;
1890
- default: return "";
1891
- }
2021
+ /**
2022
+ * Resolve a field's backfill value its declared `default` (vault#553
2023
+ * Decision B), or `undefined` when none was declared (the field stays
2024
+ * absent). Exported alongside `applySchemaDefaults` for `src/routes.ts`.
2025
+ */
2026
+ export function defaultForField(field: { default?: unknown }): unknown {
2027
+ return field.default;
1892
2028
  }
1893
2029
 
1894
2030
  // ---------------------------------------------------------------------------
package/core/src/notes.ts CHANGED
@@ -1512,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
1512
1512
  return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
1513
1513
  }
1514
1514
 
1515
- export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
1515
+ /** Options accepted by {@link deleteTag} (vault#552). */
1516
+ export interface DeleteTagOpts {
1517
+ /**
1518
+ * Proceed with the delete even though another tag's `parent_names`
1519
+ * references this one — strip the reference from every referencing tag's
1520
+ * `parent_names` array as part of the same transaction. Accepted as a
1521
+ * synonym of `detach` (both name the identical repair: remove the stale
1522
+ * reference, never delete the referencing tag itself); offered as two
1523
+ * flags because operators reach for either word depending on whether
1524
+ * they're thinking "cascade the delete" or "detach the children."
1525
+ */
1526
+ cascade?: boolean;
1527
+ /** Synonym of `cascade` — see above. */
1528
+ detach?: boolean;
1529
+ }
1530
+
1531
+ export type DeleteTagResult =
1532
+ | { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
1533
+ | { error: "tag_referenced_as_parent"; referencing_tags: string[] };
1534
+
1535
+ /**
1536
+ * Delete a tag: drop its identity row, untag every note carrying it, and
1537
+ * release any indexed fields it exclusively declared. Notes themselves are
1538
+ * NEVER deleted — only untagged.
1539
+ *
1540
+ * Referential integrity (vault#552): if another tag's `parent_names` array
1541
+ * names `name`, deleting it would silently orphan that reference — the
1542
+ * child tag's hierarchy edge would point at a name with no identity row
1543
+ * (the exact "renamed-away tag remains a live query surface" class of bug
1544
+ * the gardener found, one hop over from rename). Refuse by default with
1545
+ * `{ error: "tag_referenced_as_parent", referencing_tags }`; pass
1546
+ * `opts.cascade` or `opts.detach` (either — see {@link DeleteTagOpts}) to
1547
+ * proceed, stripping `name` from every referencing tag's `parent_names` in
1548
+ * the same transaction as the delete.
1549
+ */
1550
+ export function deleteTag(db: Database, name: string, opts?: DeleteTagOpts): DeleteTagResult {
1516
1551
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
1517
1552
  | { fields: string | null }
1518
1553
  | null;
1519
1554
  if (!row) return { deleted: false, notes_untagged: 0 };
1520
1555
 
1521
- const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
1522
- const notesUntagged = countRow.c;
1523
-
1524
- // Release any indexed fields this tag declared BEFORE the row drops.
1525
- // `releaseField` only drops the generated column + index when this tag is
1526
- // the last live declarer (co-declaration guard) a field co-declared by
1527
- // another live tag keeps its column and just loses this tag from the set.
1528
- // This lives in the store-level delete (not the MCP layer) so every caller
1529
- // — MCP delete-tag, the REST DELETE /tags/:name route, the import
1530
- // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
1531
- // bug report.
1532
- if (row.fields) {
1533
- try {
1534
- const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
1535
- for (const [fieldName, spec] of Object.entries(fields)) {
1536
- if (spec?.indexed === true) {
1537
- releaseField(db, fieldName, name);
1556
+ // Referential-integrity guard: who names `name` as a parent? Reuses the
1557
+ // SAME parent_names parsing loadTagHierarchy uses everywhere else in the
1558
+ // hierarchy (rather than a bespoke LIKE scan) so "who references this
1559
+ // tag" answers identically here as it does for query expansion.
1560
+ const hierarchy = loadTagHierarchy(db);
1561
+ const referencing = Array.from(hierarchy.childrenOf.get(name) ?? []);
1562
+ if (referencing.length > 0 && !opts?.cascade && !opts?.detach) {
1563
+ return { error: "tag_referenced_as_parent", referencing_tags: referencing.sort() };
1564
+ }
1565
+
1566
+ return transaction(db, (): DeleteTagResult => {
1567
+ // Strip the stale reference from every referencing tag's parent_names
1568
+ // BEFORE dropping this tag's own row — order doesn't matter for
1569
+ // correctness (different rows), but doing the repair first means a
1570
+ // mid-transaction failure never leaves the identity row gone with
1571
+ // dangling references still pointing at it.
1572
+ let parentRefsDetached = 0;
1573
+ if (referencing.length > 0) {
1574
+ const readStmt = db.prepare("SELECT parent_names FROM tags WHERE name = ?");
1575
+ const updateStmt = db.prepare("UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?");
1576
+ const now = new Date().toISOString();
1577
+ for (const refTag of referencing) {
1578
+ const r = readStmt.get(refTag) as { parent_names: string | null } | null;
1579
+ if (!r?.parent_names) continue;
1580
+ let parsed: unknown;
1581
+ try { parsed = JSON.parse(r.parent_names); } catch { continue; }
1582
+ if (!Array.isArray(parsed)) continue;
1583
+ const next = (parsed as unknown[]).filter((p) => p !== name);
1584
+ if (next.length === parsed.length) continue;
1585
+ updateStmt.run(next.length > 0 ? JSON.stringify(next) : null, now, refTag);
1586
+ parentRefsDetached++;
1587
+ }
1588
+ }
1589
+
1590
+ const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
1591
+ const notesUntagged = countRow.c;
1592
+
1593
+ // Release any indexed fields this tag declared BEFORE the row drops.
1594
+ // `releaseField` only drops the generated column + index when this tag is
1595
+ // the last live declarer (co-declaration guard) — a field co-declared by
1596
+ // another live tag keeps its column and just loses this tag from the set.
1597
+ // This lives in the store-level delete (not the MCP layer) so every caller
1598
+ // — MCP delete-tag, the REST DELETE /tags/:name route, the import
1599
+ // blow-away sweep — releases consistently. See the gitcoin orphaned-fields
1600
+ // bug report.
1601
+ if (row.fields) {
1602
+ try {
1603
+ const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
1604
+ for (const [fieldName, spec] of Object.entries(fields)) {
1605
+ if (spec?.indexed === true) {
1606
+ releaseField(db, fieldName, name);
1607
+ }
1538
1608
  }
1609
+ } catch {
1610
+ // Malformed fields JSON — nothing to release; proceed with the delete.
1539
1611
  }
1540
- } catch {
1541
- // Malformed fields JSON — nothing to release; proceed with the delete.
1542
1612
  }
1543
- }
1544
1613
 
1545
- db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
1546
- db.prepare("DELETE FROM tags WHERE name = ?").run(name);
1614
+ db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
1615
+ db.prepare("DELETE FROM tags WHERE name = ?").run(name);
1547
1616
 
1548
- return { deleted: true, notes_untagged: notesUntagged };
1617
+ return {
1618
+ deleted: true,
1619
+ notes_untagged: notesUntagged,
1620
+ ...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
1621
+ };
1622
+ });
1549
1623
  }
1550
1624
 
1551
1625
  // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
@@ -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