@openparachute/vault 0.5.1 → 0.5.2-rc.2

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
@@ -99,13 +99,31 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
99
99
  // Tool generation
100
100
  // ---------------------------------------------------------------------------
101
101
 
102
+ /**
103
+ * Options for {@link generateMcpTools}.
104
+ *
105
+ * `expandVisibility` (vault security review) is an OPTIONAL per-note
106
+ * visibility predicate threaded into the wikilink-expansion context for
107
+ * `query-notes`. When provided, `expand_links` inlining leaves any wikilink
108
+ * whose target fails the predicate UNRESOLVED — so a tag-scoped MCP session
109
+ * can't inline out-of-scope note content during expansion (the filtering
110
+ * happens DURING expansion, not after). Core stays scope-unaware: it
111
+ * receives a plain `(note) => boolean` closure and never imports the
112
+ * server's tag-scope module. Omitted (every internal / unscoped caller) →
113
+ * expansion behaves exactly as before.
114
+ */
115
+ export interface GenerateMcpToolsOpts {
116
+ expandVisibility?: (note: Note) => boolean;
117
+ }
118
+
102
119
  /**
103
120
  * Generate the consolidated MCP tools for a vault. Surface (10):
104
121
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
105
122
  * delete-tag, find-path, vault-info, prune-schema (admin).
106
123
  */
107
- export function generateMcpTools(store: Store): McpToolDef[] {
124
+ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
108
125
  const db: Database = (store as any).db;
126
+ const expandVisibility = opts?.expandVisibility;
109
127
 
110
128
  return [
111
129
 
@@ -178,7 +196,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
178
196
  type: "object",
179
197
  description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
180
198
  },
181
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
199
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
182
200
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
183
201
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
184
202
  date_filter: {
@@ -217,6 +235,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
217
235
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
218
236
  },
219
237
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
238
+ include_link_count: {
239
+ type: "boolean",
240
+ description:
241
+ "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
242
+ },
243
+ link_count_direction: {
244
+ type: "string",
245
+ enum: ["both", "outbound", "inbound"],
246
+ description:
247
+ "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
248
+ },
220
249
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
221
250
  expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
222
251
  expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
@@ -235,7 +264,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
235
264
  ),
236
265
  );
237
266
  const expandCtx: ExpandContext | null = expandLinks
238
- ? { db, mode: expandMode, expanded: new Set() }
267
+ ? {
268
+ db,
269
+ mode: expandMode,
270
+ expanded: new Set(),
271
+ // Tag-scope confidentiality (security review): when a visibility
272
+ // predicate was injected, wikilinks to out-of-scope notes are
273
+ // left unresolved DURING inlining — never embedded. Unscoped
274
+ // callers pass no predicate and inlining is unchanged.
275
+ ...(expandVisibility ? { isVisible: expandVisibility } : {}),
276
+ }
239
277
  : null;
240
278
 
241
279
  // --- Single note by ID/path ---
@@ -256,10 +294,26 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
256
294
  if (params.include_attachments) {
257
295
  result.attachments = await store.getAttachments(note.id);
258
296
  }
297
+ // linkCount injected after filterMetadata on purpose — same as
298
+ // links/attachments above; filterMetadata only touches `metadata`.
299
+ if (params.include_link_count) {
300
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
301
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
302
+ }
259
303
  return result;
260
304
  }
261
305
 
262
306
  // --- Build near-scope (graph-filtered set of allowed IDs) ---
307
+ //
308
+ // Tag-scope policy for `near[]` (output-filter, not hop-guard): core
309
+ // is scope-unaware, so this BFS walks the FULL graph from the anchor —
310
+ // including out-of-scope intermediate hops. For a tag-scoped session
311
+ // the server's `applyTagScopeWrappers` (mcp-tools.ts) tag-filters the
312
+ // RESULT list AFTER execute, so out-of-scope notes never survive into
313
+ // the response — no content/ids leak. This is ASYMMETRIC with
314
+ // `find-path`, which guards every hop (it returns the path itself, so
315
+ // an out-of-scope intermediary would be a leak there). The asymmetry is
316
+ // deliberate; tracked at vault#439.
263
317
  let nearScope: Set<string> | null = null;
264
318
  if (params.near) {
265
319
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
@@ -390,6 +444,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
390
444
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
391
445
  }
392
446
 
447
+ // --- Opt-in link degree (vault feedback #4) ---
448
+ // ONE batch count over all result ids (NOT per-note), so the field
449
+ // stays O(2 index scans) per request regardless of page size.
450
+ // Injected on the same objects the enrichment loop copies below.
451
+ // Ordering: runs AFTER the filterMetadata pass above on purpose —
452
+ // filterMetadata only touches the `metadata` key, so linkCount
453
+ // survives. Don't casually swap the order.
454
+ if (params.include_link_count) {
455
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
456
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
457
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
458
+ }
459
+
393
460
  // --- Hydrate links/attachments per note if requested ---
394
461
  if (params.include_links || params.include_attachments) {
395
462
  const enrichedOut: any[] = [];
@@ -543,7 +610,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
543
610
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
544
611
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
545
612
  - For batch: pass a \`notes\` array, each with an \`id\` field.
546
- - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
613
+ - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
547
614
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
548
615
  - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
549
616
  inputSchema: {
@@ -567,7 +634,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
567
634
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
568
635
  created_at: { type: "string", description: "New created_at timestamp" },
569
636
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
570
- force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
637
+ force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
571
638
  if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
572
639
  tags: {
573
640
  type: "object",
@@ -609,6 +676,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
609
676
  type: "boolean",
610
677
  description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
611
678
  },
679
+ include_links: {
680
+ type: "boolean",
681
+ description: "Echo the note's hydrated inbound + outbound links on the response (vault feedback #8). Links are *also* echoed automatically whenever the update itself mutated links (`links.add`/`links.remove`), so you rarely need to set this — its purpose is to fetch the current link set on an update that didn't touch links. Default: `false` (and absent from the response unless mutated or requested). Mirrors `query-notes`'s `include_links`. This top-level flag applies to the single-note form only; for a batch, set `include_links` on each note object in `notes` (a top-level `include_links` is ignored when `notes` is present).",
682
+ },
612
683
  // Batch
613
684
  notes: {
614
685
  type: "array",
@@ -632,10 +703,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
632
703
  metadata: { type: "object" },
633
704
  created_at: { type: "string" },
634
705
  if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
635
- force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
706
+ force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
636
707
  if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
637
708
  tags: { type: "object" },
638
709
  links: { type: "object" },
710
+ include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
639
711
  },
640
712
  required: ["id"],
641
713
  },
@@ -657,6 +729,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
657
729
  // sync-loop caller (Gitcoin Brain et al) reads this to know which
658
730
  // path fired without doing a separate query. vault#309.
659
731
  const createdIds = new Set<string>();
732
+ // Track which note IDs should echo hydrated links on the response.
733
+ // A note qualifies when this request mutated its links
734
+ // (`links.add`/`links.remove`) OR the caller set `include_links`.
735
+ // vault feedback #8 — previously the update response omitted links
736
+ // entirely, forcing a re-query just to confirm a link the caller had
737
+ // just added/removed. Per-item on batch. Note IDs (not item indices)
738
+ // key this so the create-on-missing branch, which assigns the id
739
+ // late, can register correctly.
740
+ const echoLinkIds = new Set<string>();
660
741
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
661
742
  // failure (precondition error, content_edit miss, ConflictError, …)
662
743
  // rolls back every prior mutation in the batch — see #236.
@@ -745,6 +826,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
745
826
  const fresh = noteOps.getNote(db, created.id) ?? created;
746
827
  updated.push(fresh);
747
828
  createdIds.add(fresh.id);
829
+ // Echo links if this create-on-missing declared `links.add`
830
+ // (the only link op honored on create) or asked explicitly.
831
+ if (linksAdd !== undefined || item.include_links === true) {
832
+ echoLinkIds.add(fresh.id);
833
+ }
748
834
  continue;
749
835
  }
750
836
  // Fallthrough: not-found + no if_missing → existing error
@@ -907,6 +993,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
907
993
  }
908
994
  }
909
995
 
996
+ // Echo links if this update mutated them (`links.add`/`links.remove`)
997
+ // or the caller asked explicitly. vault feedback #8.
998
+ const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
999
+ if (linkMutated || item.include_links === true) {
1000
+ echoLinkIds.add(note.id);
1001
+ }
1002
+
910
1003
  // Re-read for final state
911
1004
  updated.push(noteOps.getNote(db, note.id) ?? result);
912
1005
  }
@@ -929,11 +1022,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
929
1022
  const final = updated.map((n) => {
930
1023
  const validated = attachValidationStatus(store, db, n);
931
1024
  const created = createdIds.has(n.id);
932
- if (includeContent) return { ...validated, created } as Note & { created: boolean };
1025
+ // Echo hydrated links when this note was flagged for it (mutated
1026
+ // its links or `include_links` was set). Additive key, present only
1027
+ // when triggered — mirrors the GET / query-notes shape exactly via
1028
+ // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1029
+ const echoLinks = echoLinkIds.has(n.id);
1030
+ if (includeContent) {
1031
+ const full: any = { ...validated, created };
1032
+ if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1033
+ return full as Note & { created: boolean };
1034
+ }
933
1035
  const lean: any = noteOps.toNoteIndex(validated);
934
1036
  const vs = (validated as any).validation_status;
935
1037
  if (vs !== undefined) lean.validation_status = vs;
936
1038
  lean.created = created;
1039
+ // Carry the link echo across the lean conversion — `toNoteIndex`
1040
+ // drops unknown fields.
1041
+ if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
937
1042
  return lean;
938
1043
  });
939
1044
  return batch ? final : final[0];
@@ -1029,7 +1134,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1029
1134
  {
1030
1135
  name: "update-tag",
1031
1136
  requiredVerb: "write",
1032
- description: "Create or update a tag's identity row: description, indexed-field schemas, typed-link relationships, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1137
+ description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1033
1138
  inputSchema: {
1034
1139
  type: "object",
1035
1140
  properties: {
@@ -1051,16 +1156,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1051
1156
  },
1052
1157
  relationships: {
1053
1158
  type: "object",
1054
- description: 'Typed-link declarations. Each value declares { target_tag, cardinality, description? }. Cardinality is one of: one | optional | many | many-required. Phase 1: informational, not enforced at write time. E.g., { "lives_in": { "target_tag": "place", "cardinality": "one" } }',
1055
- additionalProperties: {
1056
- type: "object",
1057
- properties: {
1058
- target_tag: { type: "string", description: "Tag the relationship points at" },
1059
- cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
1060
- description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
1061
- },
1062
- required: ["target_tag", "cardinality"],
1063
- },
1159
+ description: 'Opaque relationship-vocabulary map: keys are relationship names, values are arbitrary JSON the declaring app interprets. Vault stores and returns the values verbatim and does NOT enforce any inner shape — only that this is a JSON object (a map), not an array or primitive. Replaces any prior map wholesale when provided; pass null to clear. The historical typed shape { "lives_in": { "target_tag": "place", "cardinality": "one" } } is still a valid value, as is any app-defined shape e.g. { "works-on": { "from": "person", "to": "project" } }.',
1160
+ additionalProperties: true,
1064
1161
  },
1065
1162
  parent_names: {
1066
1163
  type: "array",
@@ -1124,10 +1221,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1124
1221
  }
1125
1222
  }
1126
1223
 
1127
- // ---- relationships: replace wholesale when provided. Validate
1128
- // shape + cardinality vocabulary before persisting so a malformed
1129
- // payload can't leave the row in an inconsistent state.
1130
- let relationshipsPatch: Record<string, tagSchemaOps.TagRelationship> | null | undefined;
1224
+ // ---- relationships: replace wholesale when provided. `relationships`
1225
+ // is an opaque vocabulary map (relationship-name arbitrary JSON the
1226
+ // app interprets). Validate only that it's a JSON object (a map), then
1227
+ // persist verbatim no inner-shape enforcement.
1228
+ let relationshipsPatch: tagSchemaOps.TagRelationshipMap | null | undefined;
1131
1229
  if (params.relationships === null) {
1132
1230
  relationshipsPatch = null;
1133
1231
  } else if (params.relationships !== undefined) {
@@ -1382,6 +1480,16 @@ function normalizeTags(tag: unknown): string[] | undefined {
1382
1480
  return [tag as string];
1383
1481
  }
1384
1482
 
1483
+ /**
1484
+ * Coerce the `link_count_direction` MCP param to a known value, defaulting
1485
+ * to "both" (matches the REST `parseLinkCountDirection` fallback). A typo
1486
+ * silently degrades to the documented default rather than erroring.
1487
+ */
1488
+ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound" {
1489
+ if (v === "outbound" || v === "inbound") return v;
1490
+ return "both";
1491
+ }
1492
+
1385
1493
  // Re-exported for backward compat; defined in notes.ts alongside the
1386
1494
  // conditional-UPDATE implementation that raises it. AmbiguousPathError
1387
1495
  // joins the set (vault#331 N2) so external callers can `instanceof`
package/core/src/notes.ts CHANGED
@@ -736,6 +736,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
736
736
  // be at the mercy of SQLite's row order and the next page could
737
737
  // miss or duplicate one.
738
738
  orderBy = "n.updated_at ASC, n.id ASC";
739
+ } else if (opts.orderBy === "link_count") {
740
+ // `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
741
+ // dateFilter block above, it bypasses `requireIndexedField` (it's not a
742
+ // metadata column). Sort by link DEGREE using the SAME directional-sum
743
+ // definition as the `linkCount` response field (see `getLinkCounts` in
744
+ // links.ts): two correlated COUNT subqueries summed. This MUST stay a
745
+ // sum of two directional counts — a single
746
+ // `COUNT(*) ... WHERE source_id=n.id OR target_id=n.id` would count a
747
+ // self-loop ONCE (degree 1) and DIVERGE from the field's degree-2. Both
748
+ // subqueries ride the existing `idx_links_source` / `idx_links_target`
749
+ // B-trees. `created_at` stays the stable tiebreaker.
750
+ //
751
+ // Always the both-directions degree — inbound-only ordering is a future
752
+ // extension and is not built here.
753
+ //
754
+ // Perf caveat: these are correlated subqueries, evaluated once per
755
+ // candidate row. At small-to-moderate vault sizes (tens of thousands of
756
+ // notes) that's fine — each subquery is an O(log n) index probe. At very
757
+ // large vault sizes the per-row scan cost grows; the upgrade path is a
758
+ // maintained `link_count` counter column on `notes`, incremented in
759
+ // `createLink` and decremented in `deleteLink`, then ordered directly.
760
+ // NOT built now — flagged so a future contributor sees the lever.
761
+ orderBy =
762
+ `((SELECT COUNT(*) FROM links WHERE source_id = n.id) ` +
763
+ `+ (SELECT COUNT(*) FROM links WHERE target_id = n.id)) ${direction}, ` +
764
+ `n.created_at ${direction}`;
739
765
  } else if (opts.orderBy) {
740
766
  requireIndexedField(db, opts.orderBy);
741
767
  // `orderBy` came from indexed_fields (validated on declaration), so
@@ -1553,6 +1579,15 @@ export function getVaultStats(
1553
1579
  const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
1554
1580
  const linkCount = linkCountRow.c;
1555
1581
 
1582
+ // Total content bytes. CAST(content AS BLOB) forces SQLite's LENGTH() to
1583
+ // count UTF-8 BYTES rather than characters (bare LENGTH on TEXT returns a
1584
+ // char count, which undercounts multibyte content). COALESCE because SUM
1585
+ // over zero rows is NULL. See VaultStats.contentBytes for the rationale.
1586
+ const contentBytesRow = db
1587
+ .prepare("SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) as b FROM notes")
1588
+ .get() as { b: number };
1589
+ const contentBytes = contentBytesRow.b;
1590
+
1556
1591
  return {
1557
1592
  totalNotes,
1558
1593
  earliestNote: earliestRow
@@ -1566,6 +1601,7 @@ export function getVaultStats(
1566
1601
  tagCount,
1567
1602
  attachmentCount,
1568
1603
  linkCount,
1604
+ contentBytes,
1569
1605
  };
1570
1606
  }
1571
1607
 
@@ -794,6 +794,37 @@ describe("importPortableVault", async () => {
794
794
  expect(typed!.metadata).toEqual({ source: "git://x" });
795
795
  });
796
796
 
797
+ it("preserves an opaque relationship-vocabulary map across export → import → re-export (vault#428)", async () => {
798
+ const vocab = {
799
+ "works-on": { from: "person", to: "project" },
800
+ "member-of": { from: "person", to: "organization" },
801
+ "partner-of": { from: "person", to: "person" },
802
+ "based-at": { from: "project", to: "place", note: "freeform" },
803
+ };
804
+ await store.upsertTagRecord("person", {
805
+ description: "a human",
806
+ relationships: vocab,
807
+ });
808
+ const outDir = join(tmpBase, "out");
809
+ await exportVaultToDir(store, { outDir, exportedAt: "2026-05-13T00:00:00.000Z" });
810
+
811
+ const target = new SqliteStore(new Database(":memory:"));
812
+ const stats = await importPortableVault(target, { inDir: outDir });
813
+ expect(stats.schemas_restored).toBe(1);
814
+
815
+ // Imported value matches the original verbatim.
816
+ const restored = await target.getTagRecord("person");
817
+ expect(restored?.relationships).toEqual(vocab);
818
+
819
+ // Re-export from the restored store and confirm the schema file is
820
+ // byte-identical to the first export (deep round-trip stability).
821
+ const outDir2 = join(tmpBase, "out2");
822
+ await exportVaultToDir(target, { outDir: outDir2, exportedAt: "2026-05-13T00:00:00.000Z" });
823
+ const schemaA = readFileSync(join(outDir, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
824
+ const schemaB = readFileSync(join(outDir2, SIDECAR_DIR, "schemas", "person.yaml"), "utf-8");
825
+ expect(schemaB).toBe(schemaA);
826
+ });
827
+
797
828
  it("skips typed links whose target is missing from the import set", async () => {
798
829
  // Source note has a typed link to a target we don't include in
799
830
  // the export (synthetic — write the .md file by hand).
@@ -862,6 +893,15 @@ describe("portable-md round-trip — byte-equivalent re-export after blow-away i
862
893
  description: "A long-running effort",
863
894
  fields: { status: { type: "string", enum: ["active", "done"] } },
864
895
  });
896
+ // Opaque relationship-vocabulary map (vault#428) — exercises that the
897
+ // round-trip preserves an arbitrary app-defined relationships shape,
898
+ // not just the historical { target_tag, cardinality } one.
899
+ await store.upsertTagRecord("project", {
900
+ relationships: {
901
+ "works-on": { from: "person", to: "project" },
902
+ "based-at": { from: "project", to: "place", note: "freeform" },
903
+ },
904
+ });
865
905
  const n1 = await store.createNote("alpha body", {
866
906
  id: "01HX001",
867
907
  path: "Inbox/alpha",
@@ -30,10 +30,13 @@ CREATE TABLE IF NOT EXISTS notes (
30
30
  -- description — human-readable blurb (markdown).
31
31
  -- fields — JSON: indexed metadata field declarations per
32
32
  -- query-operators.md. Replaces v6-era tag_schemas.fields.
33
- -- relationships — JSON: typed-link declarations
34
- -- ({ "rel": { target_tag, cardinality, description? } }).
35
- -- Cardinality vocabulary: one | optional | many | many-required.
36
- -- Phase 1 informational declared but not enforced at write.
33
+ -- relationships — JSON: opaque relationship-vocabulary map. Keys are
34
+ -- relationship names; values are arbitrary JSON the declaring
35
+ -- app interprets (e.g. the Weaver's { "works-on": { from, to } }).
36
+ -- Stored + returned verbatim; only the top level is validated
37
+ -- (must be a JSON object/map). The historical typed shape
38
+ -- { "rel": { target_tag, cardinality, description? } } remains a
39
+ -- valid value. Not enforced at write time. See vault#428.
37
40
  -- parent_names — JSON array of parent tag names. Replaces the v6-era
38
41
  -- _tags/NAME config-note hierarchy.
39
42
  CREATE TABLE IF NOT EXISTS tags (
package/core/src/store.ts CHANGED
@@ -572,7 +572,7 @@ export class BunSqliteStore implements Store {
572
572
  patch: {
573
573
  description?: string | null;
574
574
  fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
575
- relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
575
+ relationships?: tagSchemaOps.TagRelationshipMap | null;
576
576
  parent_names?: string[] | null;
577
577
  },
578
578
  ) {
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
33
33
  }
34
34
 
35
35
  /**
36
- * Cardinality vocabulary for typed relationships. Names rather than
37
- * algebra so AI clients reading `list-tags` can reason about intent
38
- * directly. Phase 1 is informational declarations are not enforced
39
- * at write time. See patterns/tag-data-model.md §Typed relationships.
36
+ * Cardinality vocabulary for the historical typed-relationship shape.
37
+ * Names rather than algebra so AI clients reading `list-tags` can reason
38
+ * about intent directly. Retained for callers that still want the typed
39
+ * `{ target_tag, cardinality }` declaration but `relationships` is now an
40
+ * opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
41
+ * so this is one valid value shape among many, not a required one.
42
+ * See patterns/tag-data-model.md §Relationships.
40
43
  */
41
44
  export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
42
45
 
@@ -47,12 +50,24 @@ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
47
50
  "many-required",
48
51
  ] as const;
49
52
 
53
+ /**
54
+ * The historical typed-relationship declaration. Still a valid opaque-map
55
+ * value — vault no longer enforces it. New apps (the Weaver / structural-link
56
+ * picker) declare their own freeform vocabulary instead.
57
+ */
50
58
  export interface TagRelationship {
51
59
  target_tag: string;
52
60
  cardinality: TagRelCardinality;
53
61
  description?: string;
54
62
  }
55
63
 
64
+ /**
65
+ * `relationships` is an opaque vocabulary map: relationship-name → arbitrary
66
+ * JSON value the declaring app interprets. Vault stores and returns the values
67
+ * verbatim and enforces only that the top-level value is a JSON object (a map).
68
+ */
69
+ export type TagRelationshipMap = Record<string, unknown>;
70
+
56
71
  /**
57
72
  * Schema-only view of a tag — the historical shape. Backwards-compatible
58
73
  * with v13-and-earlier callers.
@@ -67,7 +82,7 @@ export interface TagSchema {
67
82
  * Full tag record — schema + typed relationships + hierarchy parents.
68
83
  */
69
84
  export interface TagRecord extends TagSchema {
70
- relationships?: Record<string, TagRelationship>;
85
+ relationships?: TagRelationshipMap;
71
86
  parent_names?: string[];
72
87
  created_at?: string;
73
88
  updated_at?: string;
@@ -115,7 +130,7 @@ export function upsertTagRecord(
115
130
  patch: {
116
131
  description?: string | null;
117
132
  fields?: Record<string, TagFieldSchema> | null;
118
- relationships?: Record<string, TagRelationship> | null;
133
+ relationships?: TagRelationshipMap | null;
119
134
  parent_names?: string[] | null;
120
135
  },
121
136
  ): TagRecord {
@@ -226,56 +241,56 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
226
241
  }
227
242
 
228
243
  // ---------------------------------------------------------------------------
229
- // Validation — typed relationships
244
+ // Validation — relationships (opaque vocabulary map)
230
245
  // ---------------------------------------------------------------------------
231
246
 
232
247
  /**
233
- * Validate a `relationships` payload before persisting. Returns the
234
- * canonicalized object on success; throws Error with a user-readable
235
- * message on the first violation. Rules:
248
+ * Validate a `relationships` payload before persisting. `relationships` is
249
+ * an **opaque vocabulary map**: a JSON object whose keys are relationship
250
+ * names and whose values are arbitrary JSON the declaring app interprets
251
+ * (e.g. the Weaver / structural-link picker's `{ "works-on": { from, to } }`
252
+ * shape). Vault does not enforce any inner structure — it stores and returns
253
+ * the values verbatim.
254
+ *
255
+ * Rules (the only ones):
256
+ * - The top-level value must be a plain JSON object (a map). A top-level
257
+ * array or primitive is rejected — relationships is a map, not a list.
258
+ * - The payload must be JSON-serializable (no circular refs / functions /
259
+ * bigints), since it's persisted as a JSON column.
236
260
  *
237
- * - Each value must declare `target_tag` (non-empty string) and
238
- * `cardinality` from the named vocabulary.
239
- * - `description` is optional, must be a string when present.
240
- * - Relationship keys must be non-empty strings.
261
+ * Returns the value verbatim (round-trips through JSON.parse(JSON.stringify)
262
+ * to both prove serializability and strip anything non-serializable). The
263
+ * historical typed shape `{ target_tag, cardinality }` is a valid opaque map,
264
+ * so this is a backwards-compatible superset — existing typed declarations
265
+ * and callers keep working unchanged.
266
+ *
267
+ * Phase 1 was already informational ("declarations are not enforced at write
268
+ * time"); dropping the inner-shape gate is consistent with that intent.
241
269
  */
242
- export function validateRelationships(
243
- raw: unknown,
244
- ): Record<string, TagRelationship> {
270
+ export function validateRelationships(raw: unknown): Record<string, unknown> {
245
271
  if (raw === null || raw === undefined) {
246
272
  throw new Error("relationships: expected an object, got null/undefined");
247
273
  }
248
274
  if (typeof raw !== "object" || Array.isArray(raw)) {
249
- throw new Error("relationships: expected an object mapping rel name → declaration");
275
+ throw new Error(
276
+ "relationships: expected an object mapping relationship name → value (got an array or primitive)",
277
+ );
250
278
  }
251
- const out: Record<string, TagRelationship> = {};
252
- for (const [rel, decl] of Object.entries(raw as Record<string, unknown>)) {
253
- if (!rel || typeof rel !== "string") {
279
+ for (const rel of Object.keys(raw as Record<string, unknown>)) {
280
+ if (!rel) {
254
281
  throw new Error("relationships: keys must be non-empty strings");
255
282
  }
256
- if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
257
- throw new Error(`relationships["${rel}"]: declaration must be an object`);
258
- }
259
- const d = decl as Record<string, unknown>;
260
- if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
261
- throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
262
- }
263
- const card = d.cardinality;
264
- if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
265
- throw new Error(
266
- `relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
267
- );
268
- }
269
- if (d.description !== undefined && typeof d.description !== "string") {
270
- throw new Error(`relationships["${rel}"]: description must be a string when set`);
271
- }
272
- out[rel] = {
273
- target_tag: d.target_tag,
274
- cardinality: card as TagRelCardinality,
275
- ...(d.description !== undefined ? { description: d.description as string } : {}),
276
- };
277
283
  }
278
- return out;
284
+ // Round-trip through JSON to (a) confirm the payload is serializable —
285
+ // the column is stored as JSON — and (b) return a clean, owned copy with
286
+ // no non-JSON values lingering. Throws on circular refs / bigint / etc.
287
+ let serialized: string;
288
+ try {
289
+ serialized = JSON.stringify(raw);
290
+ } catch (err) {
291
+ throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
292
+ }
293
+ return JSON.parse(serialized) as Record<string, unknown>;
279
294
  }
280
295
 
281
296
  // ---------------------------------------------------------------------------
@@ -287,7 +302,7 @@ function rowToRecord(row: TagRow): TagRecord {
287
302
  tag: row.name,
288
303
  description: row.description ?? undefined,
289
304
  fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
290
- relationships: parseJson<Record<string, TagRelationship>>(row.relationships),
305
+ relationships: parseJson<TagRelationshipMap>(row.relationships),
291
306
  parent_names: parseJson<string[]>(row.parent_names),
292
307
  created_at: row.created_at ?? undefined,
293
308
  updated_at: row.updated_at ?? undefined,