@openparachute/vault 0.7.0-rc.7 → 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,6 +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";
19
+ import {
20
+ resolveOrQueueLink,
21
+ resolveStructuredLinkNote,
22
+ getUnresolvedLinksForNote,
23
+ getUnresolvedLinksForNotes,
24
+ } from "./wikilinks.js";
18
25
  import * as tagSchemaOps from "./tag-schemas.js";
19
26
  import type { TagFieldSchema } from "./tag-schemas.js";
20
27
  import {
@@ -194,6 +201,25 @@ export interface GenerateMcpToolsOpts {
194
201
  * Omitted (unscoped / internal callers) → the full graph is walked.
195
202
  */
196
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;
197
223
  }
198
224
 
199
225
  /**
@@ -207,6 +233,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
207
233
  const db: Database = store.db;
208
234
  const expandVisibility = opts?.expandVisibility;
209
235
  const nearTraversable = opts?.nearTraversable;
236
+ const ifExistsVisible = opts?.ifExistsVisible;
210
237
  // Write-attribution (vault#298) — captured once at tool-generation time
211
238
  // (a fresh tool set is generated per MCP request, so this is request-scoped)
212
239
  // and folded into every create/update the tools perform.
@@ -263,10 +290,14 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
263
290
 
264
291
  Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
265
292
 
293
+ Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
294
+
266
295
  Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
267
296
 
268
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.
269
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
+
270
301
  Response shape (vault#550 — three variants, pick by what you passed):
271
302
  - Default (no \`cursor\`, no warnings): a bare array of notes.
272
303
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
@@ -319,6 +350,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
319
350
  },
320
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." },
321
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)." },
322
354
  path: { type: "string", description: "Exact path match (case-insensitive)" },
323
355
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
324
356
  extension: {
@@ -401,6 +433,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
401
433
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
402
434
  },
403
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`." },
404
437
  include_link_count: {
405
438
  type: "boolean",
406
439
  description:
@@ -458,6 +491,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
458
491
  // `expand`).
459
492
  if (contentRange && !includeContent) throw contentRangeRequiresContent();
460
493
  let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
494
+ // --- Attach validation_status (vault#555 fix 3) ---
495
+ // Mirrors create/update-note's `attachValidationStatus` — additive,
496
+ // present only when at least one tag on the note declares
497
+ // `fields`. Before this, `validation_status` (including an
498
+ // `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
499
+ // on the one-time create/update WRITE response; a caller reading
500
+ // the note back via `query-notes` (the natural way to re-check a
501
+ // value after write) saw nothing at all — contradicting the
502
+ // documented "advisory violations surface as warnings" for every
503
+ // read after the initial write.
504
+ {
505
+ const status = store.validateNoteAgainstSchemas({
506
+ path: note.path,
507
+ tags: note.tags,
508
+ metadata: note.metadata as Record<string, unknown> | undefined,
509
+ });
510
+ if (status) result.validation_status = status;
511
+ }
461
512
  if (expandCtx && includeContent && typeof result.content === "string") {
462
513
  // Mark the top-level note as already expanded so it can't recursively inline itself.
463
514
  expandCtx.expanded.add(note.id);
@@ -471,6 +522,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
471
522
  if (params.include_links) {
472
523
  result.links = linkOps.getLinksHydrated(db, note.id);
473
524
  }
525
+ if (params.include_broken_links) {
526
+ result.broken_links = getUnresolvedLinksForNote(db, note.id);
527
+ }
474
528
  if (params.include_attachments) {
475
529
  result.attachments = await store.getAttachments(note.id);
476
530
  }
@@ -661,6 +715,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
661
715
  excludeTags,
662
716
  hasTags: params.has_tags as boolean | undefined,
663
717
  hasLinks: params.has_links as boolean | undefined,
718
+ hasBrokenLinks: params.has_broken_links as boolean | undefined,
664
719
  path: params.path as string | undefined,
665
720
  pathPrefix: params.path_prefix as string | undefined,
666
721
  extension: params.extension as string | string[] | undefined,
@@ -738,6 +793,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
738
793
  for (const n of output) applyContentRange(n, contentRange);
739
794
  }
740
795
 
796
+ // --- Attach validation_status (vault#555 fix 3) ---
797
+ // Same reasoning as the single-note path above — additive, present
798
+ // only when at least one tag on the note declares `fields`. Runs
799
+ // BEFORE metadata filtering so it sees the note's full metadata
800
+ // regardless of `include_metadata`; the `validation_status` key
801
+ // itself survives `filterMetadata` (which only touches `metadata`).
802
+ {
803
+ const statusById = new Map(
804
+ results.map((n) => [
805
+ n.id,
806
+ store.validateNoteAgainstSchemas({
807
+ path: n.path,
808
+ tags: n.tags,
809
+ metadata: n.metadata as Record<string, unknown> | undefined,
810
+ }),
811
+ ]),
812
+ );
813
+ for (const n of output as any[]) {
814
+ const status = statusById.get(n.id);
815
+ if (status) n.validation_status = status;
816
+ }
817
+ }
818
+
741
819
  // --- Apply metadata filtering ---
742
820
  if (includeMetadata !== undefined && includeMetadata !== true) {
743
821
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
@@ -756,8 +834,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
756
834
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
757
835
  }
758
836
 
759
- // --- Hydrate links/attachments per note if requested ---
760
- 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) {
761
839
  // Links hydrate for the WHOLE page in a constant number of
762
840
  // queries (see getLinksHydratedForNotes) — the per-note variant
763
841
  // cost (1 link query + 1 summary query + N tag queries) × page
@@ -765,10 +843,15 @@ Response shape (vault#550 — three variants, pick by what you passed):
765
843
  const linksByNote = params.include_links
766
844
  ? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
767
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;
768
850
  const enrichedOut: any[] = [];
769
851
  for (const n of output as any[]) {
770
852
  const enriched: any = { ...n };
771
853
  if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
854
+ if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
772
855
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
773
856
  enrichedOut.push(enriched);
774
857
  }
@@ -806,7 +889,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
806
889
  {
807
890
  name: "create-note",
808
891
  requiredVerb: "write",
809
- 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 \`[]\`.`,
810
903
  inputSchema: {
811
904
  type: "object",
812
905
  properties: {
@@ -826,24 +919,33 @@ Response shape (vault#550 — three variants, pick by what you passed):
826
919
  },
827
920
  required: ["target", "relationship"],
828
921
  },
829
- description: "Links to create from this note",
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.",
830
923
  },
831
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
+ },
832
934
  // Batch
833
935
  notes: {
834
936
  type: "array",
835
937
  items: {
836
938
  type: "object",
837
939
  properties: {
838
- 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)." },
839
941
  path: { type: "string" },
840
942
  extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
841
943
  metadata: { type: "object" },
842
944
  tags: { type: "array", items: { type: "string" } },
843
945
  links: { type: "array" },
844
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." },
845
948
  },
846
- required: ["content"],
847
949
  },
848
950
  description: "Array of notes for batch creation",
849
951
  },
@@ -858,12 +960,152 @@ Response shape (vault#550 — three variants, pick by what you passed):
858
960
  }
859
961
 
860
962
  const created: Note[] = [];
963
+ // Structured `links` are resolved in a SECOND pass, after every
964
+ // note in this batch has been created (vault#555) — resolving
965
+ // inline (the old behavior) meant a link from item 0 to item 1's
966
+ // path silently dropped, since item 1 didn't exist yet at the
967
+ // moment item 0's links were processed. Deferring makes
968
+ // within-batch forward-refs resolve exactly like a same-content
969
+ // [[wikilink]] already does. `pendingLinks` carries the SOURCE
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.
973
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
974
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
975
+ // Populated in the second pass; folded into each note's response
976
+ // below, same pattern as `validation_status`.
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
+
861
1099
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
862
1100
  // failure rolls back every prior insert — see #236. This guards
863
1101
  // anything thrown from store.createNote / createLink (path
864
1102
  // conflict, etc.). Single-item calls skip the wrap to avoid
865
1103
  // colliding with concurrent callers on the shared bun:sqlite
866
- // 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.
867
1109
  const batched = items.length > 1;
868
1110
  const runBatch = async (): Promise<void> => {
869
1111
  for (const item of items) {
@@ -873,6 +1115,28 @@ Response shape (vault#550 — three variants, pick by what you passed):
873
1115
  const extension = item.extension !== undefined
874
1116
  ? validateExtension(item.extension)
875
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
+
876
1140
  // Strict-schema gate (vault#299) — reject before any write so a
877
1141
  // mid-batch violation rolls back the batch transaction.
878
1142
  enforceStrict({
@@ -880,30 +1144,70 @@ Response shape (vault#550 — three variants, pick by what you passed):
880
1144
  tags: item.tags as string[] | undefined,
881
1145
  metadata: item.metadata as Record<string, unknown> | undefined,
882
1146
  });
883
- const note = await store.createNote(item.content as string ?? "", {
884
- path: item.path as string | undefined,
885
- tags: item.tags as string[] | undefined,
886
- metadata: item.metadata as Record<string, unknown> | undefined,
887
- created_at: item.created_at as string | undefined,
888
- ...(extension !== undefined ? { extension } : {}),
889
- // Write-attribution (vault#298) same actor/via for every item
890
- // in a batch (the whole call came from one authenticated session).
891
- actor: writeActor,
892
- via: writeVia,
893
- });
894
-
895
- // Create explicit links (not wikilinks — those are automatic)
896
- if (item.links) {
897
- for (const link of item.links as { target: string; relationship: string }[]) {
898
- const target = resolveNote(db, link.target);
899
- if (target) {
900
- await store.createLink(note.id, target.id, link.relationship);
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;
901
1173
  }
902
1174
  }
1175
+ throw err;
1176
+ }
1177
+
1178
+ if (upsertMode) existedMap.set(note.id, false);
1179
+
1180
+ if (item.links) {
1181
+ pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
903
1182
  }
904
1183
 
905
1184
  created.push(noteOps.getNote(db, note.id) ?? note);
906
1185
  }
1186
+
1187
+ // --- Resolve structured links (vault#555) ---
1188
+ // Same semantics as [[wikilinks]]: ID or path/title match, tried
1189
+ // now that every sibling note in this batch exists. A target that
1190
+ // STILL doesn't resolve (typo, or a note that arrives in a later
1191
+ // call) is queued for lazy resolution — it backfills automatically
1192
+ // the moment a matching note is created — and surfaces an
1193
+ // `unresolved_link` warning naming the target. Never silent.
1194
+ for (const { sourceId, links } of pendingLinks) {
1195
+ for (const link of links) {
1196
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1197
+ if (targetId) {
1198
+ await store.createLink(sourceId, targetId, link.relationship);
1199
+ } else {
1200
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1201
+ list.push({
1202
+ code: "unresolved_link",
1203
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1204
+ target: link.target,
1205
+ relationship: link.relationship,
1206
+ });
1207
+ linkWarningsByNote.set(sourceId, list);
1208
+ }
1209
+ }
1210
+ }
907
1211
  };
908
1212
  await (batched ? transactionAsync(db, runBatch) : runBatch());
909
1213
 
@@ -914,9 +1218,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
914
1218
  // update-note path, which already re-reads post-defaults. The re-read
915
1219
  // is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
916
1220
  // entirely when no defaults were applied, so the common no-defaults
917
- // 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.
918
1224
  const mutatedIds = new Set<string>();
919
1225
  for (const note of created) {
1226
+ if (noMutateIds.has(note.id)) continue;
920
1227
  if (note.tags && note.tags.length > 0) {
921
1228
  for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
922
1229
  mutatedIds.add(id);
@@ -934,8 +1241,36 @@ Response shape (vault#550 — three variants, pick by what you passed):
934
1241
  })();
935
1242
 
936
1243
  // Attach `validation_status` from any tag's `fields` declaration that
937
- // applies to this note, against the post-defaults state.
938
- const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1244
+ // applies to this note, against the post-defaults state. Fold in any
1245
+ // `unresolved_link` warnings collected above (vault#555) additive,
1246
+ // present only when this note's `links` had a target that didn't
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.
1250
+ const final = refreshed.map((n) => {
1251
+ const validated = attachValidationStatus(store, db, n);
1252
+ const warnings = linkWarningsByNote.get(n.id);
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;
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
+
939
1274
  return batch ? final : final[0];
940
1275
  },
941
1276
  },
@@ -1028,7 +1363,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1028
1363
  },
1029
1364
  },
1030
1365
  },
1031
- description: "Links to add/remove",
1366
+ description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
1032
1367
  },
1033
1368
  include_content: {
1034
1369
  type: "boolean",
@@ -1122,6 +1457,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1122
1457
  // key this so the create-on-missing branch, which assigns the id
1123
1458
  // late, can register correctly.
1124
1459
  const echoLinkIds = new Set<string>();
1460
+ // Structured `links.add` entries, deferred to a second pass AFTER
1461
+ // every item in this batch has been created/updated (vault#555) —
1462
+ // resolving inline would silently drop a link from item 0 to a note
1463
+ // item 1 (later in the same batch) creates via `if_missing:
1464
+ // "create"`. Same fix as create-note's `pendingLinks`.
1465
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
1466
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
1467
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
1125
1468
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
1126
1469
  // failure (precondition error, content_edit miss, ConflictError, …)
1127
1470
  // rolls back every prior mutation in the batch — see #236.
@@ -1212,13 +1555,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1212
1555
  });
1213
1556
  const created = await store.createNote(content, createOpts);
1214
1557
  await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
1215
- // Apply links.add if the caller declared any.
1558
+ // Defer links.add resolution to the second pass (vault#555)
1559
+ // — same reasoning as create-note.
1216
1560
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1217
1561
  if (linksAdd) {
1218
- for (const link of linksAdd) {
1219
- const target = resolveNote(db, link.target);
1220
- if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
1221
- }
1562
+ pendingLinks.push({ sourceId: created.id, links: linksAdd });
1222
1563
  }
1223
1564
  const fresh = noteOps.getNote(db, created.id) ?? created;
1224
1565
  updated.push(fresh);
@@ -1326,7 +1667,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1326
1667
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1327
1668
  if (linksRemove) {
1328
1669
  for (const link of linksRemove) {
1329
- const target = resolveNote(db, link.target);
1670
+ const target = resolveStructuredLinkNote(db, link.target);
1330
1671
  if (!target) continue;
1331
1672
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1332
1673
  if (link.relationship === "wikilink" && target.path) {
@@ -1400,8 +1741,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1400
1741
  enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1401
1742
  }
1402
1743
 
1744
+ // vault#555 fix 2 — tag and link mutations must bump `updated_at`
1745
+ // too, not just core-field (content/path/metadata) changes. Before
1746
+ // this, a tags-only or links-only call with `force: true` (no
1747
+ // `if_updated_at`) left `updates` completely empty — the branch
1748
+ // below skipped `store.updateNote` ENTIRELY, so `updated_at` never
1749
+ // moved even though tags/note_tags or links genuinely changed.
1750
+ // (A tags-only call that happened to pass `if_updated_at` already
1751
+ // worked by accident — it populated `updates.if_updated_at` — which
1752
+ // is why this was easy to miss in ad hoc testing.) This breaks
1753
+ // cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
1754
+ // sync filter: the mutation is real but invisible to a
1755
+ // since-last-check loop.
1756
+ const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
1757
+ || ((item.tags as any)?.remove?.length ?? 0) > 0;
1758
+ const hasLinkMutation = (item.links as any)?.add !== undefined
1759
+ || (item.links as any)?.remove !== undefined;
1760
+
1403
1761
  let result: Note;
1404
- if (Object.keys(updates).length > 0) {
1762
+ if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
1405
1763
  // Write-attribution (vault#298): stamp the most-recent-write
1406
1764
  // columns on the same UPDATE that bumps `updated_at`. Only set when
1407
1765
  // there's a real change to write (the empty-updates branch below
@@ -1412,7 +1770,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1412
1770
  // store.updateNote routes through noteOps.updateNote, which runs
1413
1771
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
1414
1772
  // throws ConflictError on mismatch. No mutations have happened
1415
- // yet, so a throw here leaves the note untouched.
1773
+ // yet, so a throw here leaves the note untouched. `updates` may
1774
+ // carry no core fields at all (a pure tag/link mutation) — that's
1775
+ // fine: `noteOps.updateNote` unconditionally SETs
1776
+ // `updated_at`/`last_updated_by`/`last_updated_via` whenever
1777
+ // `skipUpdatedAt` isn't set, so this still issues a real UPDATE
1778
+ // (not the true no-op/precondition-only branch, which only fires
1779
+ // when the caller passes ZERO fields AND no mutation is pending).
1416
1780
  result = await store.updateNote(note.id, updates);
1417
1781
  } else {
1418
1782
  result = note;
@@ -1433,15 +1797,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1433
1797
  await store.untagNote(note.id, tagsOp.remove);
1434
1798
  }
1435
1799
 
1436
- // --- Add links ---
1800
+ // --- Add links (deferred to the second pass — vault#555) ---
1437
1801
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1438
1802
  if (linksAdd) {
1439
- for (const link of linksAdd) {
1440
- const target = resolveNote(db, link.target);
1441
- if (target) {
1442
- await store.createLink(note.id, target.id, link.relationship, link.metadata);
1443
- }
1444
- }
1803
+ pendingLinks.push({ sourceId: note.id, links: linksAdd });
1445
1804
  }
1446
1805
 
1447
1806
  // Echo links if this update mutated them (`links.add`/`links.remove`)
@@ -1454,6 +1813,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1454
1813
  // Re-read for final state
1455
1814
  updated.push(noteOps.getNote(db, note.id) ?? result);
1456
1815
  }
1816
+
1817
+ // --- Resolve structured `links.add` (vault#555) ---
1818
+ // Deferred until every item in this batch has been created/
1819
+ // updated — same [[wikilink]] semantics (ID or path/title match),
1820
+ // now tried against the FULL post-batch note set so a forward-ref
1821
+ // to a sibling item resolves. A target that still doesn't resolve
1822
+ // is queued for lazy resolution (backfills when a matching note
1823
+ // is created later) and surfaces an `unresolved_link` warning —
1824
+ // never silently dropped.
1825
+ for (const { sourceId, links } of pendingLinks) {
1826
+ for (const link of links) {
1827
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1828
+ if (targetId) {
1829
+ await store.createLink(sourceId, targetId, link.relationship, link.metadata);
1830
+ } else {
1831
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1832
+ list.push({
1833
+ code: "unresolved_link",
1834
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1835
+ target: link.target,
1836
+ relationship: link.relationship,
1837
+ });
1838
+ linkWarningsByNote.set(sourceId, list);
1839
+ }
1840
+ }
1841
+ }
1457
1842
  };
1458
1843
  await (batched ? transactionAsync(db, runBatch) : runBatch());
1459
1844
 
@@ -1475,9 +1860,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1475
1860
  // when triggered — mirrors the GET / query-notes shape exactly via
1476
1861
  // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1477
1862
  const echoLinks = echoLinkIds.has(n.id);
1863
+ // `unresolved_link` warnings (vault#555) — additive, present only
1864
+ // when this note's `links.add` had a target that didn't resolve.
1865
+ const warnings = linkWarningsByNote.get(n.id);
1478
1866
  if (includeContent) {
1479
1867
  const full: any = { ...validated, created };
1480
1868
  if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1869
+ if (warnings && warnings.length > 0) full.warnings = warnings;
1481
1870
  return full as Note & { created: boolean };
1482
1871
  }
1483
1872
  const lean: any = noteOps.toNoteIndex(validated);
@@ -1487,6 +1876,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1487
1876
  // Carry the link echo across the lean conversion — `toNoteIndex`
1488
1877
  // drops unknown fields.
1489
1878
  if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
1879
+ if (warnings && warnings.length > 0) lean.warnings = warnings;
1490
1880
  return lean;
1491
1881
  });
1492
1882
  return batch ? final : final[0];
@@ -1527,7 +1917,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1527
1917
  {
1528
1918
  name: "list-tags",
1529
1919
  requiredVerb: "read",
1530
- 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.`,
1531
1921
  inputSchema: {
1532
1922
  type: "object",
1533
1923
  properties: {
@@ -1615,7 +2005,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1615
2005
  additionalProperties: {
1616
2006
  type: "object",
1617
2007
  properties: {
1618
- 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)." },
2008
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1619
2009
  description: { type: "string" },
1620
2010
  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." },
1621
2011
  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." },
@@ -1742,7 +2132,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1742
2132
  // mgmt + future config writes; deletes are write-tier mutations.
1743
2133
  // See delete-note rationale.
1744
2134
  requiredVerb: "write",
1745
- 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.",
1746
2136
  inputSchema: {
1747
2137
  type: "object",
1748
2138
  properties: {
@@ -1898,7 +2288,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1898
2288
  // overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
1899
2289
  // `write` or read-only callers lose the stats projection.
1900
2290
  requiredVerb: "read",
1901
- 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.",
1902
2292
  inputSchema: {
1903
2293
  type: "object",
1904
2294
  properties: {
@@ -1958,7 +2348,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1958
2348
  // vault's taxonomy, not scoped to any one tag's write authority.
1959
2349
  requiredVerb: "admin",
1960
2350
  description:
1961
- "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).",
1962
2352
  inputSchema: { type: "object", properties: {} },
1963
2353
  execute: async () => {
1964
2354
  return await store.doctor();