@openparachute/vault 0.7.0-rc.8 → 0.7.0-rc.9

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
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
3
3
  import { transactionAsync } from "./txn.js";
4
4
  import * as noteOps from "./notes.js";
5
5
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
6
+ import { normalizePath } from "./paths.js";
6
7
  import { QueryError } from "./query-operators.js";
7
8
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
8
9
  import {
@@ -15,7 +16,12 @@ import {
15
16
  } from "./query-warnings.js";
16
17
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
17
18
  import * as linkOps from "./links.js";
18
- import { resolveOrQueueLink, resolveStructuredLinkNote } from "./wikilinks.js";
19
+ import {
20
+ resolveOrQueueLink,
21
+ resolveStructuredLinkNote,
22
+ getUnresolvedLinksForNote,
23
+ getUnresolvedLinksForNotes,
24
+ } from "./wikilinks.js";
19
25
  import * as tagSchemaOps from "./tag-schemas.js";
20
26
  import type { TagFieldSchema } from "./tag-schemas.js";
21
27
  import {
@@ -195,6 +201,25 @@ export interface GenerateMcpToolsOpts {
195
201
  * Omitted (unscoped / internal callers) → the full graph is walked.
196
202
  */
197
203
  nearTraversable?: (noteId: string) => boolean;
204
+ /**
205
+ * `ifExistsVisible` (vault#555 auth-review must-fix) is an OPTIONAL per-note
206
+ * predicate gating the `create-note` `if_exists` upsert. `if_exists` resolves
207
+ * the target `path` VAULT-WIDE (`getNoteByPath`) and then returns (ignore) /
208
+ * updates / replaces the found note — so without a scope gate a tag-scoped
209
+ * caller could READ or OVERWRITE an out-of-scope note just by naming its
210
+ * path. When provided, `applyExistingNote` calls this predicate on the
211
+ * RESOLVED existing note and, if it returns false, throws `PathConflictError`
212
+ * instead (the path is taken but invisible to this caller — no content
213
+ * exposed, nothing mutated). It's applied INSIDE `applyExistingNote`, which
214
+ * BOTH the proactive-check site AND the concurrent-INSERT race-backstop site
215
+ * funnel through — so a TOCTOU race (both existence checks miss, the real
216
+ * INSERT then loses to a concurrent writer's out-of-scope note) can't slip a
217
+ * note past it. Core stays scope-unaware: it receives a plain
218
+ * `(note) => boolean` closure and never imports the server's tag-scope
219
+ * module. Omitted (unscoped / internal callers) → `if_exists` resolves
220
+ * against the full vault exactly as before.
221
+ */
222
+ ifExistsVisible?: (note: Note) => boolean;
198
223
  }
199
224
 
200
225
  /**
@@ -208,6 +233,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
208
233
  const db: Database = store.db;
209
234
  const expandVisibility = opts?.expandVisibility;
210
235
  const nearTraversable = opts?.nearTraversable;
236
+ const ifExistsVisible = opts?.ifExistsVisible;
211
237
  // Write-attribution (vault#298) — captured once at tool-generation time
212
238
  // (a fresh tool set is generated per MCP request, so this is request-scoped)
213
239
  // and folded into every create/update the tools perform.
@@ -270,6 +296,8 @@ Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bo
270
296
 
271
297
  Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
272
298
 
299
+ Broken links (vault#555): a \`[[wikilink]]\` or structured \`links\` target that never resolved to a note used to be invisible — silently dropped from the response with no signal it existed. Pass \`has_broken_links: true\`/\`false\` to filter notes by whether they have any dangling outbound link, and/or \`include_broken_links: true\` to attach each note's pending targets as \`broken_links: [{target, relationship}]\` (empty array when none). Both read the vault's pending-resolution table — the same source \`create-note\`/\`update-note\`'s \`unresolved_link\` warning draws from; a target created later (this session or any future one) backfills the edge automatically and the note drops out of \`has_broken_links: true\`.
300
+
273
301
  Response shape (vault#550 — three variants, pick by what you passed):
274
302
  - Default (no \`cursor\`, no warnings): a bare array of notes.
275
303
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
@@ -322,6 +350,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
322
350
  },
323
351
  has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
324
352
  has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
353
+ has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op)." },
325
354
  path: { type: "string", description: "Exact path match (case-insensitive)" },
326
355
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
327
356
  extension: {
@@ -404,6 +433,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
404
433
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
405
434
  },
406
435
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
436
+ include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`." },
407
437
  include_link_count: {
408
438
  type: "boolean",
409
439
  description:
@@ -492,6 +522,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
492
522
  if (params.include_links) {
493
523
  result.links = linkOps.getLinksHydrated(db, note.id);
494
524
  }
525
+ if (params.include_broken_links) {
526
+ result.broken_links = getUnresolvedLinksForNote(db, note.id);
527
+ }
495
528
  if (params.include_attachments) {
496
529
  result.attachments = await store.getAttachments(note.id);
497
530
  }
@@ -682,6 +715,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
682
715
  excludeTags,
683
716
  hasTags: params.has_tags as boolean | undefined,
684
717
  hasLinks: params.has_links as boolean | undefined,
718
+ hasBrokenLinks: params.has_broken_links as boolean | undefined,
685
719
  path: params.path as string | undefined,
686
720
  pathPrefix: params.path_prefix as string | undefined,
687
721
  extension: params.extension as string | string[] | undefined,
@@ -800,8 +834,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
800
834
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
801
835
  }
802
836
 
803
- // --- Hydrate links/attachments per note if requested ---
804
- if (params.include_links || params.include_attachments) {
837
+ // --- Hydrate links/attachments/broken-links per note if requested ---
838
+ if (params.include_links || params.include_attachments || params.include_broken_links) {
805
839
  // Links hydrate for the WHOLE page in a constant number of
806
840
  // queries (see getLinksHydratedForNotes) — the per-note variant
807
841
  // cost (1 link query + 1 summary query + N tag queries) × page
@@ -809,10 +843,15 @@ Response shape (vault#550 — three variants, pick by what you passed):
809
843
  const linksByNote = params.include_links
810
844
  ? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
811
845
  : null;
846
+ // Same one-batched-query-for-the-page shape as links (vault#555).
847
+ const brokenLinksByNote = params.include_broken_links
848
+ ? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
849
+ : null;
812
850
  const enrichedOut: any[] = [];
813
851
  for (const n of output as any[]) {
814
852
  const enriched: any = { ...n };
815
853
  if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
854
+ if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
816
855
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
817
856
  enrichedOut.push(enriched);
818
857
  }
@@ -850,7 +889,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
850
889
  {
851
890
  name: "create-note",
852
891
  requiredVerb: "write",
853
- description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
892
+ description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.
893
+
894
+ **Path-conflict handling** — \`if_exists: "error"|"ignore"|"update"|"replace"\` (vault#555, default \`"error"\`): what to do when the note's \`path\` already names an existing note.
895
+ - \`"error"\` (DEFAULT — unchanged behavior): the write is rejected with a \`path_conflict\` error (409); nothing is mutated.
896
+ - \`"ignore"\`: return the existing note UNCHANGED — no error and no mutation of any kind (content/metadata/tags/links untouched; no schema-default backfill runs either). Response carries \`existed: true\`. The idempotent-retry primitive: a crash-replay or the losing side of a create-race gets back the same note a first-time caller would have created, safely, any number of times.
897
+ - \`"update"\`: merge this payload into the existing note — \`content\` (if provided) fully replaces the existing content, exactly like \`update-note\`'s \`content\` field (omit to leave it untouched); \`metadata\` (if provided) is RFC-7386 merged — existing keys preserved, incoming keys overwrite, an incoming \`null\` value deletes a key — same semantics as \`update-note\`; \`tags\`/\`links\` (if provided) are ADDED to the existing set (union — nothing already there is removed). Response carries \`existed: true\`.
898
+ - \`"replace"\`: overwrite \`content\` and \`metadata\` WHOLESALE — \`content\` becomes exactly the incoming value (or \`""\` if omitted) and \`metadata\` becomes exactly the incoming object (or \`{}\` if omitted), NOT merged, so a prior metadata key absent from this payload is dropped. \`tags\`/\`links\` stay additive (same union behavior as \`"update"\`) — a replace targets the free-form fields, not the taxonomy/graph, so it can't silently orphan links or detach tags the caller didn't mention. The note's \`id\` and \`created_at\` are preserved either way.
899
+
900
+ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` was one of \`"ignore"\`/\`"update"\`/\`"replace"\` — \`true\` when the collision branch fired, \`false\` when a normal fresh insert happened instead (including when \`path\` was never set, so there was nothing to conflict with — \`if_exists\` is a no-op without a \`path\`, but still reports \`existed: false\`). Absent entirely under the default \`"error"\` mode — a plain create-note call's response shape is byte-identical to before this feature. Batch-aware, per-item (like \`if_missing\` on \`update-note\`): set \`if_exists\` inside each \`notes[]\` entry — a top-level \`if_exists\` alongside a \`notes\` array is NOT inherited by items that omit their own (it only takes effect on the single-note form, where \`params\` IS the one item).
901
+
902
+ **Batch summary** — pass \`summary: true\` (batch/\`notes\` calls only; ignored on a single-note call) to receive a compact \`{created, ids, failed}\` shape instead of N full note objects: \`created\` counts items that resulted in a BRAND-NEW insert (excludes \`if_exists\` collisions); \`ids\` lists every resulting note id in item order (fresh creates AND \`existed\` hits alike); \`failed\` is reserved for future partial-batch-failure reporting — today a batch create is all-or-nothing (any thrown error aborts and rolls back the WHOLE call, same with or without \`summary\`), so it's always \`[]\`.`,
854
903
  inputSchema: {
855
904
  type: "object",
856
905
  properties: {
@@ -873,21 +922,30 @@ Response shape (vault#550 — three variants, pick by what you passed):
873
922
  description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
874
923
  },
875
924
  created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
925
+ if_exists: {
926
+ type: "string",
927
+ enum: ["error", "ignore", "update", "replace"],
928
+ description: "What to do when `path` already names an existing note (vault#555). See the tool description for the full contract of each mode. Default \"error\" — unchanged path_conflict behavior.",
929
+ },
930
+ summary: {
931
+ type: "boolean",
932
+ description: "Batch calls only (a `notes` array): return a compact `{created, ids, failed}` shape instead of N full note objects. See the tool description. Ignored on a single-note call.",
933
+ },
876
934
  // Batch
877
935
  notes: {
878
936
  type: "array",
879
937
  items: {
880
938
  type: "object",
881
939
  properties: {
882
- content: { type: "string" },
940
+ content: { type: "string", description: "Optional — defaults to \"\" (vault#555 fix: this item's schema previously marked it `required`, but it was never enforced; an empty-content batch item has always succeeded)." },
883
941
  path: { type: "string" },
884
942
  extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
885
943
  metadata: { type: "object" },
886
944
  tags: { type: "array", items: { type: "string" } },
887
945
  links: { type: "array" },
888
946
  created_at: { type: "string" },
947
+ if_exists: { type: "string", enum: ["error", "ignore", "update", "replace"], description: "Per-item: see top-level `if_exists` docs. Each batch item carries its own setting." },
889
948
  },
890
- required: ["content"],
891
949
  },
892
950
  description: "Array of notes for batch creation",
893
951
  },
@@ -909,19 +967,145 @@ Response shape (vault#550 — three variants, pick by what you passed):
909
967
  // moment item 0's links were processed. Deferring makes
910
968
  // within-batch forward-refs resolve exactly like a same-content
911
969
  // [[wikilink]] already does. `pendingLinks` carries the SOURCE
912
- // note id (known immediately — createNote assigns it synchronously)
913
- // alongside each item's raw `links` array.
970
+ // note id (known immediately — createNote assigns it synchronously,
971
+ // and so does the `if_exists` existing-note branch below) alongside
972
+ // each item's raw `links` array.
914
973
  const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
915
974
  // Per-note `unresolved_link` warnings (vault#555 — "never silent").
916
975
  // Populated in the second pass; folded into each note's response
917
976
  // below, same pattern as `validation_status`.
918
977
  const linkWarningsByNote = new Map<string, QueryWarning[]>();
978
+ // `if_exists` bookkeeping (vault#555). `existedMap` is set ONLY for
979
+ // items whose `if_exists` engaged (ignore/update/replace) — absent
980
+ // means the default "error" path ran, so a plain create-note call
981
+ // sees zero response-shape change. `true` = the path already named
982
+ // a note and that branch fired; `false` = if_exists was set but no
983
+ // conflict occurred (a normal fresh insert). `noMutateIds` marks
984
+ // "ignore" hits so the schema-defaults pass below skips them
985
+ // entirely — "ignore" promises NO mutation, including backfill.
986
+ const existedMap = new Map<string, boolean>();
987
+ const noMutateIds = new Set<string>();
988
+ /** Record an `if_exists` collision outcome — shared by both call sites below. */
989
+ const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
990
+ existedMap.set(resultNote.id, true);
991
+ if (noMutate) noMutateIds.add(resultNote.id);
992
+ created.push(resultNote);
993
+ };
994
+
995
+ /**
996
+ * Handle an `if_exists` collision against an already-resolved
997
+ * `existingNote` — shared by the proactive pre-check (the common,
998
+ * non-racing path) and the insert-time PathConflictError catch
999
+ * (the race-closing backstop: a concurrent writer's INSERT
1000
+ * committed between our check and ours). Returns the note to put
1001
+ * in the response and whether it must be excluded from the
1002
+ * schema-defaults mutation pass ("ignore" — genuinely untouched).
1003
+ */
1004
+ const applyExistingNote = async (
1005
+ item: any,
1006
+ existingNote: Note,
1007
+ mode: "ignore" | "update" | "replace",
1008
+ ): Promise<{ note: Note; noMutate: boolean }> => {
1009
+ // CRITICAL scope guard (vault#555 auth-review must-fix). `existingNote`
1010
+ // was resolved VAULT-WIDE by path (getNoteByPath) at whichever call
1011
+ // site reached here — the proactive check OR the concurrent-INSERT
1012
+ // race backstop. A tag-scoped MCP session injects `ifExistsVisible`
1013
+ // (see GenerateMcpToolsOpts); when the resolved note fails it, the
1014
+ // caller must not read (ignore) / update / replace an out-of-scope
1015
+ // note it named only by path — throw PathConflictError (path taken,
1016
+ // invisible to this caller) BEFORE any read or mutation, so no
1017
+ // content leaks and nothing is written. Placing it HERE — not only in
1018
+ // the server-layer wrapper's pre-check — is what closes the
1019
+ // race-backstop TOCTOU: the pre-check + core's proactive check can
1020
+ // BOTH miss a note a concurrent writer then INSERTs, and the backstop
1021
+ // re-resolves it into this exact call. Core stays scope-unaware: it
1022
+ // only invokes the injected closure. Unscoped/internal callers pass no
1023
+ // predicate → unchanged behavior.
1024
+ if (ifExistsVisible && !ifExistsVisible(existingNote)) {
1025
+ throw new noteOps.PathConflictError(existingNote.path ?? "");
1026
+ }
1027
+ if (mode === "ignore") {
1028
+ // No error, no mutation of any kind — not even schema-default
1029
+ // backfill. Return the existing note exactly as stored.
1030
+ return { note: existingNote, noMutate: true };
1031
+ }
1032
+
1033
+ // "update" / "replace" both apply tags/links additively (union) —
1034
+ // they differ only in how content/metadata combine with the
1035
+ // existing values. See the tool description for the full contract.
1036
+ const updates: { content?: string; metadata?: Record<string, unknown> } = {};
1037
+ if (mode === "update") {
1038
+ if (item.content !== undefined) updates.content = item.content as string;
1039
+ if (item.metadata !== undefined) {
1040
+ updates.metadata = noteOps.mergeMetadata(
1041
+ existingNote.metadata as Record<string, unknown> | null | undefined,
1042
+ item.metadata as Record<string, unknown>,
1043
+ );
1044
+ }
1045
+ } else {
1046
+ // "replace" — PUT semantics: content/metadata become exactly
1047
+ // this payload (empty default when omitted), never merged.
1048
+ updates.content = (item.content as string | undefined) ?? "";
1049
+ updates.metadata = (item.metadata as Record<string, unknown> | undefined) ?? {};
1050
+ }
1051
+
1052
+ const incomingTags = (item.tags as string[] | undefined) ?? [];
1053
+ const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
1054
+ const projectedMetadata =
1055
+ updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
1056
+ // Strict-schema gate (vault#299) against the PROJECTED final shape
1057
+ // (existing ∪ incoming), not the raw incoming item — a caller
1058
+ // updating one field of an already-conforming note shouldn't have
1059
+ // to re-supply every `required` field just to pass validation.
1060
+ enforceStrict({ path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
1061
+
1062
+ // vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): a
1063
+ // tags-only or links-only "update" leaves `updates` empty, so
1064
+ // gating store.updateNote on `updates` ALONE would skip it — and
1065
+ // then a genuine tag/link mutation (store.tagNote below) would never
1066
+ // bump `updated_at`, breaking cursor polling (`ORDER BY updated_at`)
1067
+ // and any since-last-check sync. Gate on the tag/link mutation too,
1068
+ // mirroring the update-note/PATCH path's hasTagMutation||hasLinkMutation.
1069
+ // store.updateNote with empty core fields still issues a real UPDATE
1070
+ // that bumps updated_at (+ last_updated_by/via). "replace" always
1071
+ // sets content+metadata, so it was never affected.
1072
+ const hasLinkMutation = item.links !== undefined;
1073
+ let result: Note = existingNote;
1074
+ if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
1075
+ result = await store.updateNote(existingNote.id, {
1076
+ ...updates,
1077
+ actor: writeActor,
1078
+ via: writeVia,
1079
+ });
1080
+ }
1081
+
1082
+ if (incomingTags.length > 0) {
1083
+ await store.tagNote(result.id, incomingTags);
1084
+ // Note: applySchemaDefaults also runs in the outer batch loop for
1085
+ // this note (it's not in `noMutateIds` — only "ignore" hits are),
1086
+ // so this inline call is redundant. Harmless (idempotent — fills
1087
+ // only still-missing fields), kept so the update/replace branch is
1088
+ // self-contained; left un-gated to avoid coupling to the outer loop.
1089
+ await applySchemaDefaults(store, db, [result.id], incomingTags);
1090
+ }
1091
+
1092
+ if (item.links) {
1093
+ pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
1094
+ }
1095
+
1096
+ return { note: noteOps.getNote(db, result.id) ?? result, noMutate: false };
1097
+ };
1098
+
919
1099
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
920
1100
  // failure rolls back every prior insert — see #236. This guards
921
1101
  // anything thrown from store.createNote / createLink (path
922
1102
  // conflict, etc.). Single-item calls skip the wrap to avoid
923
1103
  // colliding with concurrent callers on the shared bun:sqlite
924
- // connection.
1104
+ // connection. Catching a PathConflictError INSIDE this callback
1105
+ // (the if_exists race backstop below) does NOT roll back the
1106
+ // transaction — only a throw that escapes `runBatch` does (see
1107
+ // core/src/txn.ts) — so the fallback-to-existing-note path commits
1108
+ // normally alongside every other item in the batch.
925
1109
  const batched = items.length > 1;
926
1110
  const runBatch = async (): Promise<void> => {
927
1111
  for (const item of items) {
@@ -931,6 +1115,28 @@ Response shape (vault#550 — three variants, pick by what you passed):
931
1115
  const extension = item.extension !== undefined
932
1116
  ? validateExtension(item.extension)
933
1117
  : undefined;
1118
+ const effectiveExtension = extension ?? "md";
1119
+ const ifExists = (item.if_exists as string | undefined) ?? "error";
1120
+ const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
1121
+
1122
+ // Proactive existence check (vault#555) — only possible when a
1123
+ // path is set (a pathless create can never conflict). Handles
1124
+ // the common, non-racing case cleanly: skip the raw-item strict
1125
+ // gate entirely (it would validate the wrong shape — see
1126
+ // `applyExistingNote`) and go straight to the ignore/update/
1127
+ // replace branch. The insert-time catch below is the backstop
1128
+ // for the rare true race.
1129
+ let existingNote: Note | null = null;
1130
+ if (upsertMode && item.path) {
1131
+ const normalized = normalizePath(item.path as string);
1132
+ existingNote = normalized ? noteOps.getNoteByPath(db, normalized, effectiveExtension) : null;
1133
+ }
1134
+ if (existingNote) {
1135
+ const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
1136
+ recordExistedBranch(resultNote, noMutate);
1137
+ continue;
1138
+ }
1139
+
934
1140
  // Strict-schema gate (vault#299) — reject before any write so a
935
1141
  // mid-batch violation rolls back the batch transaction.
936
1142
  enforceStrict({
@@ -938,17 +1144,38 @@ Response shape (vault#550 — three variants, pick by what you passed):
938
1144
  tags: item.tags as string[] | undefined,
939
1145
  metadata: item.metadata as Record<string, unknown> | undefined,
940
1146
  });
941
- const note = await store.createNote(item.content as string ?? "", {
942
- path: item.path as string | undefined,
943
- tags: item.tags as string[] | undefined,
944
- metadata: item.metadata as Record<string, unknown> | undefined,
945
- created_at: item.created_at as string | undefined,
946
- ...(extension !== undefined ? { extension } : {}),
947
- // Write-attribution (vault#298) same actor/via for every item
948
- // in a batch (the whole call came from one authenticated session).
949
- actor: writeActor,
950
- via: writeVia,
951
- });
1147
+ let note: Note;
1148
+ try {
1149
+ note = await store.createNote(item.content as string ?? "", {
1150
+ path: item.path as string | undefined,
1151
+ tags: item.tags as string[] | undefined,
1152
+ metadata: item.metadata as Record<string, unknown> | undefined,
1153
+ created_at: item.created_at as string | undefined,
1154
+ ...(extension !== undefined ? { extension } : {}),
1155
+ // Write-attribution (vault#298) — same actor/via for every item
1156
+ // in a batch (the whole call came from one authenticated session).
1157
+ actor: writeActor,
1158
+ via: writeVia,
1159
+ });
1160
+ } catch (err) {
1161
+ // Race backstop (vault#555): a concurrent writer's INSERT
1162
+ // committed between our proactive check (skipped or missed
1163
+ // above) and this one. Re-resolve the now-existing row and
1164
+ // fall through to the SAME branch a proactive hit would have
1165
+ // taken — closes the create-race gap even under true
1166
+ // concurrency, not just the sequential common case.
1167
+ if (upsertMode && err instanceof noteOps.PathConflictError) {
1168
+ const winner = noteOps.getNoteByPath(db, err.path, effectiveExtension);
1169
+ if (winner) {
1170
+ const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
1171
+ recordExistedBranch(resultNote, noMutate);
1172
+ continue;
1173
+ }
1174
+ }
1175
+ throw err;
1176
+ }
1177
+
1178
+ if (upsertMode) existedMap.set(note.id, false);
952
1179
 
953
1180
  if (item.links) {
954
1181
  pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
@@ -991,9 +1218,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
991
1218
  // update-note path, which already re-reads post-defaults. The re-read
992
1219
  // is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
993
1220
  // entirely when no defaults were applied, so the common no-defaults
994
- // path adds zero extra reads.
1221
+ // path adds zero extra reads. `noMutateIds` ("ignore" hits) are
1222
+ // skipped entirely — "ignore" promises zero mutation, including
1223
+ // backfill of a since-added default the existing note predates.
995
1224
  const mutatedIds = new Set<string>();
996
1225
  for (const note of created) {
1226
+ if (noMutateIds.has(note.id)) continue;
997
1227
  if (note.tags && note.tags.length > 0) {
998
1228
  for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
999
1229
  mutatedIds.add(id);
@@ -1014,14 +1244,33 @@ Response shape (vault#550 — three variants, pick by what you passed):
1014
1244
  // applies to this note, against the post-defaults state. Fold in any
1015
1245
  // `unresolved_link` warnings collected above (vault#555) — additive,
1016
1246
  // present only when this note's `links` had a target that didn't
1017
- // resolve.
1247
+ // resolve. `existed` (vault#555) is attached ONLY for items whose
1248
+ // `if_exists` actually engaged — the default "error" path's response
1249
+ // shape is untouched.
1018
1250
  const final = refreshed.map((n) => {
1019
1251
  const validated = attachValidationStatus(store, db, n);
1020
1252
  const warnings = linkWarningsByNote.get(n.id);
1021
- return warnings && warnings.length > 0
1022
- ? ({ ...validated, warnings } as Note & { warnings: QueryWarning[] })
1023
- : validated;
1253
+ const existed = existedMap.get(n.id);
1254
+ let out: Note & { warnings?: QueryWarning[]; existed?: boolean } = validated;
1255
+ if (warnings && warnings.length > 0) out = { ...out, warnings };
1256
+ if (existed !== undefined) out = { ...out, existed };
1257
+ return out;
1024
1258
  });
1259
+
1260
+ // Batch summary (vault#555) — compact shape instead of N full note
1261
+ // objects. Batch-only (a `notes` array); `summary` is ignored on a
1262
+ // single-note call, matching `if_exists`'s per-item batch scoping.
1263
+ if (batch && params.summary === true) {
1264
+ return {
1265
+ created: final.filter((n: any) => n.existed !== true).length,
1266
+ ids: final.map((n) => n.id),
1267
+ // Reserved for future partial-batch-failure reporting — a batch
1268
+ // create is all-or-nothing today (see the tool description), so
1269
+ // this is always empty.
1270
+ failed: [] as unknown[],
1271
+ };
1272
+ }
1273
+
1025
1274
  return batch ? final : final[0];
1026
1275
  },
1027
1276
  },
@@ -1668,7 +1917,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1668
1917
  {
1669
1918
  name: "list-tags",
1670
1919
  requiredVerb: "read",
1671
- description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
1920
+ description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag. NOTE (vault#555): this list includes zero-membership tags (\`count: 0\` — a declared schema never yet applied, or a tag every note was since untagged from), so its length can run higher than \`vault-info\`'s stats \`tagCount\`, which counts only tags at least one note currently carries.`,
1672
1921
  inputSchema: {
1673
1922
  type: "object",
1674
1923
  properties: {
@@ -1883,7 +2132,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1883
2132
  // mgmt + future config writes; deletes are write-tier mutations.
1884
2133
  // See delete-note rationale.
1885
2134
  requiredVerb: "write",
1886
- 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.",
2135
+ 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. Also refused with error_type \"tag_in_use_by_tokens\" (vault#555 fix — this case existed pre-#555 but was undocumented here; see \"merge-tags\" for the identical guard) when the tag is referenced by a tag-scoped token's allowlist — revoke or re-mint the token(s) first. A no-op on a tag with no identity row and no notes returns {deleted: false, notes_untagged: 0} rather than erroring.",
1887
2136
  inputSchema: {
1888
2137
  type: "object",
1889
2138
  properties: {
@@ -2039,7 +2288,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2039
2288
  // overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
2040
2289
  // `write` or read-only callers lose the stats projection.
2041
2290
  requiredVerb: "read",
2042
- description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
2291
+ description: "Get a comprehensive vault projection: name, description, `coordinates` (this vault's own REST/MCP URL templates — `{name, base_url, rest_api, mcp}`, always present), tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, query hints, and (when a seeded onboarding guide exists) a `getting_started` note pointer. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution as a `stats` field. Pass `description` to update the vault description (changes how AI agents behave in future sessions) — requires the `vault:write` scope for this vault even though the tool itself is read-gated (vault#555: a `vault:read`-only caller passing `description` gets a `Forbidden` rejection, not a silent no-op). Call this anytime mid-session to refresh schema context. NOTE (vault#555): the stats `tagCount` counts only tags at least one note currently carries (`COUNT(DISTINCT tag_name)` over note-tag memberships) — `list-tags`'s row count can run higher because it also lists zero-membership tags (an identity row from a declared schema or a since-untagged tag). Neither is wrong; they answer different questions.",
2043
2292
  inputSchema: {
2044
2293
  type: "object",
2045
2294
  properties: {
@@ -2099,7 +2348,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2099
2348
  // vault's taxonomy, not scoped to any one tag's write authority.
2100
2349
  requiredVerb: "admin",
2101
2350
  description:
2102
- "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).",
2351
+ "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. Returns {findings, summary, scanned_at} — findings is an array, each entry {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).",
2103
2352
  inputSchema: { type: "object", properties: {} },
2104
2353
  execute: async () => {
2105
2354
  return await store.doctor();
package/core/src/notes.ts CHANGED
@@ -851,6 +851,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
851
851
  );
852
852
  }
853
853
 
854
+ // Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
855
+ // structured `links` target that never resolved. The `unresolved_wikilinks`
856
+ // table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
857
+ // a link actually goes unresolved — a vault where nothing ever has won't
858
+ // have the table at all. Check existence first rather than reference it
859
+ // unconditionally: a read-only query filter shouldn't have the side effect
860
+ // of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
861
+ // table would throw "no such table" instead of the correct empty answer.
862
+ if (opts.hasBrokenLinks !== undefined) {
863
+ const unresolvedTableExists = db.prepare(
864
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_wikilinks'",
865
+ ).get() !== null;
866
+ if (!unresolvedTableExists) {
867
+ // No table yet means no note has ever had a broken link:
868
+ // hasBrokenLinks:true matches nothing; hasBrokenLinks:false is a
869
+ // no-op (every note already qualifies) — add no condition at all.
870
+ if (opts.hasBrokenLinks) conditions.push("0 = 1");
871
+ } else {
872
+ conditions.push(
873
+ opts.hasBrokenLinks
874
+ ? `EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`
875
+ : `NOT EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`,
876
+ );
877
+ }
878
+ }
879
+
854
880
  // ID set filter — used by `near` to push neighborhood scoping into SQL so
855
881
  // that LIMIT applies to the neighborhood, not the whole notes table.
856
882
  if (opts.ids !== undefined) {
@@ -1238,6 +1264,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1238
1264
  excludeTags: opts.excludeTags,
1239
1265
  hasTags: opts.hasTags,
1240
1266
  hasLinks: opts.hasLinks,
1267
+ hasBrokenLinks: opts.hasBrokenLinks,
1241
1268
  path: opts.path,
1242
1269
  pathPrefix: opts.pathPrefix,
1243
1270
  extension: opts.extension,
@@ -291,6 +291,20 @@ describe("getting-started pack", () => {
291
291
  expect(GETTING_STARTED_CONTENT).toContain("over MCP");
292
292
  });
293
293
 
294
+ test("Getting Started carries the journaling / agent-memory / search conventions (vault#555)", () => {
295
+ expect(GETTING_STARTED_CONTENT).toContain("## A few shapes worth reusing");
296
+ // Journaling: indexed entry_date + mood enum.
297
+ expect(GETTING_STARTED_CONTENT).toContain("entry_date");
298
+ expect(GETTING_STARTED_CONTENT).toContain("mood");
299
+ // Agent-memory pattern: thread + messages + the if_exists:"ignore" crash-replay recipe.
300
+ expect(GETTING_STARTED_CONTENT).toContain("#thread");
301
+ expect(GETTING_STARTED_CONTENT).toContain("#message");
302
+ expect(GETTING_STARTED_CONTENT).toContain('if_exists: "ignore"');
303
+ // Search one-liner: literal by default, search_mode:"advanced" for FTS syntax.
304
+ expect(GETTING_STARTED_CONTENT).toContain("literal by default");
305
+ expect(GETTING_STARTED_CONTENT).toContain('search_mode: "advanced"');
306
+ });
307
+
294
308
  test("default vault-description constants orient + tell the AI to self-replace them", () => {
295
309
  for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
296
310
  expect(d.length).toBeGreaterThan(100);
@@ -481,6 +481,29 @@ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
481
481
  \`order_by\`; all tags declaring the same field must agree on its \`type\` and
482
482
  \`indexed\` flag.
483
483
 
484
+ ## A few shapes worth reusing
485
+
486
+ Testers of this vault independently reinvented these — start from them instead:
487
+
488
+ - **Journaling.** One tag (\`#journal\`) with an indexed \`entry_date\`
489
+ (\`{ type: "string", indexed: true }\`, ISO date) and a \`mood\` enum
490
+ (\`{ type: "string", enum: [...] }\`). Indexing \`entry_date\` (see "Only
491
+ \`indexed: true\` fields support operator queries" above) is what makes
492
+ date-range queries (\`metadata: { entry_date: { gte: "2026-01-01" } }\`) and
493
+ \`order_by: "entry_date"\` work.
494
+ - **Agent memory (thread + messages, crash-replay-safe).** Model a
495
+ conversation as one \`#thread\` note (metadata: \`status\`, \`cursor\`) plus many
496
+ \`#message\` notes linked to it (\`relationship: "in-thread"\`), each carrying
497
+ its own \`status\`. If your write loop can crash and replay the same message,
498
+ give it a stable \`path\` (e.g. \`Threads/<thread-id>/msg-<n>\`) and create it
499
+ with \`if_exists: "ignore"\` — a retry after a crash safely returns the
500
+ already-written message instead of creating a duplicate, no
501
+ query-before-write round trip needed.
502
+ - **Search.** \`query-notes { search: "..." }\` is literal by default — your
503
+ text is escaped, not parsed as FTS5 syntax, so punctuation like "didn't" or
504
+ "18.6" just works. Pass \`search_mode: "advanced"\` only when you actually
505
+ want FTS5 boolean/phrase/prefix syntax.
506
+
484
507
  ## Write gotchas
485
508
 
486
509
  A few behaviors worth knowing before you write at scale:
package/core/src/types.ts CHANGED
@@ -141,6 +141,15 @@ export interface QueryOpts {
141
141
  // `hasLinks` checks both directions — inbound or outbound counts as "has links".
142
142
  hasTags?: boolean;
143
143
  hasLinks?: boolean;
144
+ /**
145
+ * Presence filter on the `unresolved_wikilinks` table (vault#555):
146
+ * `true` → only notes with at least one dangling outbound link (a
147
+ * `[[wikilink]]` or structured `links` target that never resolved to a
148
+ * note); `false` → only notes with none. Safe on a vault where the
149
+ * `unresolved_wikilinks` table has never been created (no note has ever
150
+ * had a broken link) — `true` matches nothing, `false` is a no-op.
151
+ */
152
+ hasBrokenLinks?: boolean;
144
153
  path?: string; // exact path match (case-insensitive)
145
154
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
146
155
  /**