@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4

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.
Files changed (38) hide show
  1. package/core/src/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
@@ -3,6 +3,7 @@ import type { Note } from "./types.js";
3
3
  import * as linkOps from "./links.js";
4
4
  import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
5
5
  import { chunkForInClause } from "./sql-in.js";
6
+ import { pathTitle } from "./paths.js";
6
7
  import { transaction } from "./txn.js";
7
8
  import type { QueryWarning } from "./query-warnings.js";
8
9
 
@@ -107,6 +108,117 @@ function stripCode(content: string): string {
107
108
  return result;
108
109
  }
109
110
 
111
+ /**
112
+ * Rewrite the TARGET portion of `[[wikilinks]]` in `content`, in place.
113
+ *
114
+ * `rename` is called with each bracket's parsed target (trimmed, exactly as
115
+ * {@link parseWikilinks} would report it) and returns the replacement target
116
+ * string, or `null`/`undefined` to leave that bracket alone. Everything else
117
+ * about the bracket survives verbatim — the `!` embed marker, a `|display`
118
+ * alias, a `#Heading` anchor, a `#^block-ref`, and any surrounding text.
119
+ *
120
+ * Brackets inside fenced/inline code are skipped, because {@link stripCode}
121
+ * is applied before matching — same as the parser. That is deliberate: a
122
+ * `[[link]]` in a code fence is not a link (it is never parsed, never
123
+ * resolved, never given a `links` row), so a rename must not silently edit
124
+ * someone's sample text. The pre-vault#708 cascade rewrote those too, via a
125
+ * blind content-wide regex.
126
+ */
127
+ export function rewriteWikilinkTargets(
128
+ content: string,
129
+ rename: (target: string) => string | null | undefined,
130
+ ): string {
131
+ const stripped = stripCode(content);
132
+ const regex = /(!)?\[\[([^\[\]\n]+?)\]\]/g;
133
+ let out = "";
134
+ let last = 0;
135
+ let match: RegExpExecArray | null;
136
+
137
+ while ((match = regex.exec(stripped)) !== null) {
138
+ const inner = match[2]!;
139
+ // Target part = everything before the first `#` (anchor/block-ref) or
140
+ // `|` (display alias), whichever comes first. Matches how
141
+ // `parseWikilinks` splits, for every ordering of the two.
142
+ let boundary = inner.length;
143
+ const hashIdx = inner.indexOf("#");
144
+ const pipeIdx = inner.indexOf("|");
145
+ if (hashIdx !== -1) boundary = Math.min(boundary, hashIdx);
146
+ if (pipeIdx !== -1) boundary = Math.min(boundary, pipeIdx);
147
+ const targetPart = inner.slice(0, boundary);
148
+ const target = targetPart.trim();
149
+ if (!target) continue;
150
+
151
+ const next = rename(target);
152
+ if (!next || next === targetPart) continue;
153
+
154
+ // `stripCode` preserves offsets, so the match indices address `content`.
155
+ const start = match.index;
156
+ const end = start + match[0].length;
157
+ const innerStart = start + (match[1] ? 3 : 2);
158
+ out += content.slice(last, innerStart) + next + content.slice(innerStart + boundary, end);
159
+ last = end;
160
+ }
161
+
162
+ return last === 0 ? content : out + content.slice(last);
163
+ }
164
+
165
+ /**
166
+ * How a wikilink target names a note PATH — the shapes
167
+ * {@link resolveWikilink} can match a path by, in its resolution order.
168
+ * `null` means the target is not a path-derived name for `path` at all (e.g.
169
+ * it resolved through the H1-title fallback, which a repath doesn't affect).
170
+ *
171
+ * Used by the rename cascade (vault#708) to rewrite a bracket into the SAME
172
+ * shape it already had rather than forcing every reference to a full path.
173
+ */
174
+ export type WikilinkPathForm =
175
+ | { form: "path" }
176
+ | { form: "title" }
177
+ | { form: "path-ext"; ext: string };
178
+
179
+ export function wikilinkPathForm(target: string, path: string): WikilinkPathForm | null {
180
+ const lower = target.toLowerCase();
181
+ const title = pathTitle(path);
182
+ // Order mirrors `resolveWikilink`: a literal path wins over the
183
+ // explicit-extension reading of the same string (`Recipe.v2`).
184
+ if (lower === path.toLowerCase()) return { form: "path" };
185
+ if (lower === title.toLowerCase()) return { form: "title" };
186
+ // Only the FULL-path `.ext` form exists: `resolveWikilink`'s
187
+ // explicit-extension rule matches on `(path, extension)`, so a
188
+ // basename+ext bracket (`[[Data.csv]]` for `move/Data`) never resolved in
189
+ // the first place and is not this cascade's to repair.
190
+ const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
191
+ if (extMatch && extMatch[1]!.toLowerCase() === path.toLowerCase()) {
192
+ return { form: "path-ext", ext: extMatch[2]! };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * The candidate replacement texts for a bracket of `form` after the note
199
+ * moved to `newPath`, most-preferred first. The caller picks the first one
200
+ * that resolves back to the renamed note (see `Store.cascadeRename`):
201
+ * a basename bracket stays a basename bracket unless the new basename would
202
+ * now be ambiguous (or collide across extensions), in which case it widens
203
+ * to the full path.
204
+ */
205
+ export function wikilinkRenameCandidates(
206
+ form: WikilinkPathForm,
207
+ newPath: string,
208
+ noteExtension: string | null | undefined,
209
+ ): string[] {
210
+ const newTitle = pathTitle(newPath);
211
+ const ext = noteExtension || "md";
212
+ switch (form.form) {
213
+ case "path":
214
+ return [newPath, `${newPath}.${ext}`];
215
+ case "title":
216
+ return [newTitle, newPath, `${newPath}.${ext}`];
217
+ case "path-ext":
218
+ return [`${newPath}.${form.ext}`];
219
+ }
220
+ }
221
+
110
222
  // ---------------------------------------------------------------------------
111
223
  // Resolution — match wikilink targets to notes by path
112
224
  // ---------------------------------------------------------------------------
@@ -336,6 +448,86 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
336
448
  return { unresolved, count: total };
337
449
  }
338
450
 
451
+ /**
452
+ * The `ambiguous_wikilinks` rows that are BROKEN in a tag-scoped reader's own
453
+ * sub-vault — every candidate invisible to it — shaped as
454
+ * {@link UnresolvedWikilink} so `/unresolved-wikilinks` can report them
455
+ * alongside the genuinely-unresolved ones (vault#239).
456
+ *
457
+ * Without this the admin listing is the third face of the same delete-time
458
+ * oracle `getUnresolvedLinksForNotes` closes on the note surfaces: the row
459
+ * only appears there once the last invisible candidate is deleted and
460
+ * `refreshAmbiguousLinks` demotes it. Callers are expected to apply their own
461
+ * source-note tag-scope filter afterwards, exactly as they do for the base
462
+ * listing. Scanned under the same `limit` slice as
463
+ * {@link listUnresolvedWikilinks}; `[]` when the table has never been created
464
+ * (and never called at all for an unscoped reader, whose answer is the
465
+ * persisted tables as-is).
466
+ */
467
+ export function listZeroVisibleAmbiguousAsUnresolved(
468
+ db: Database,
469
+ visible: (noteId: string) => boolean,
470
+ limit = 50,
471
+ ): UnresolvedWikilink[] {
472
+ let rows: { source_id: string; target_path: string; relationship: string }[];
473
+ try {
474
+ rows = db.prepare(
475
+ "SELECT source_id, target_path, relationship FROM ambiguous_wikilinks ORDER BY source_id LIMIT ?",
476
+ ).all(limit) as typeof rows;
477
+ } catch {
478
+ return []; // Table doesn't exist — nothing has ever been ambiguous here.
479
+ }
480
+ const out: UnresolvedWikilink[] = [];
481
+ for (const r of rows) {
482
+ const relationship = r.relationship || WIKILINK_REL;
483
+ if (visibleResolutionCount(db, r.target_path, relationship, visible) > 0) continue;
484
+ const note = db.prepare("SELECT path FROM notes WHERE id = ?").get(r.source_id) as { path: string | null } | null;
485
+ out.push({
486
+ source_id: r.source_id,
487
+ source_path: note?.path ?? undefined,
488
+ target_path: r.target_path,
489
+ relationship,
490
+ });
491
+ }
492
+ return out;
493
+ }
494
+
495
+ /**
496
+ * Resolved-to-invisible content wikilinks, shaped for the scoped unresolved
497
+ * listing (vault#714). Bounded and non-exhaustive: scan at most 2000 edges,
498
+ * fetch content only for sources with a hidden target, and stop at limit.
499
+ * Structured links cannot recover their original caller string from links;
500
+ * never disclose the hidden target's stored path as a replacement.
501
+ */
502
+ export function listResolvedToInvisibleAsUnresolved(
503
+ db: Database,
504
+ visible: (noteId: string) => boolean,
505
+ limit = 50,
506
+ ): UnresolvedWikilink[] {
507
+ if (limit <= 0) return [];
508
+ const rows = db.prepare(
509
+ "SELECT source_id, target_id, relationship FROM links ORDER BY source_id, target_id LIMIT ?",
510
+ ).all(Math.min(2000, Math.max(200, limit * 20))) as { source_id: string; target_id: string; relationship: string }[];
511
+ const sources = new Set<string>();
512
+ const out: UnresolvedWikilink[] = [];
513
+ for (const row of rows) {
514
+ if (visible(row.target_id) || sources.has(row.source_id)) continue;
515
+ sources.add(row.source_id);
516
+ const note = db.prepare("SELECT path, content FROM notes WHERE id = ?").get(row.source_id) as { path: string | null; content: string } | null;
517
+ if (!note) continue;
518
+ const seen = new Set<string>();
519
+ for (const { target } of parseWikilinks(note.content)) {
520
+ if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
521
+ const key = target.toLowerCase();
522
+ if (seen.has(key)) continue;
523
+ seen.add(key);
524
+ out.push({ source_id: row.source_id, source_path: note.path ?? undefined, target_path: target, relationship: WIKILINK_REL });
525
+ if (out.length >= limit) return out;
526
+ }
527
+ }
528
+ return out;
529
+ }
530
+
339
531
  /** One note's dangling outbound link, as surfaced on a note read (vault#555). */
340
532
  export interface BrokenLink {
341
533
  target: string;
@@ -347,11 +539,35 @@ export interface BrokenLink {
347
539
  * `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
348
540
  * query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
349
541
  * not one per note. Returns a map with an entry (possibly `[]`) for every
350
- * requested id whenever the table exists; when the table has never been
351
- * created (no note in this vault has ever had a broken link) every id maps
352
- * to `[]` without a query attempt.
542
+ * requested id.
543
+ *
544
+ * `visible` (vault#239) is an OPTIONAL per-candidate-note predicate, injected
545
+ * by the server layer for a TAG-SCOPED reader — the SAME closure the
546
+ * ambiguity surface uses, because "which of this target's candidates can the
547
+ * reader see" is one question with two answers. Core stays scope-unaware; it
548
+ * only invokes the closure.
549
+ *
550
+ * When it is supplied, the persisted `ambiguous_wikilinks` rows are folded in
551
+ * too, as broken, whenever NONE of their candidates is visible: such a target
552
+ * matches nothing in the reader's sub-vault, which is what "broken" means.
553
+ * Without that, `refreshAmbiguousLinks`' delete-time demotion into
554
+ * `unresolved_wikilinks` is the only thing that ever makes the note broken —
555
+ * so the answer moves when notes the reader cannot see are deleted, which is
556
+ * an oracle for exactly the cross-scope naming collision
557
+ * {@link getAmbiguousLinksForNotes} narrows away.
558
+ *
559
+ * Cost: one extra query plus one re-resolution per ambiguous row, and only
560
+ * for scoped readers — the same profile the ambiguity surface accepts, on a
561
+ * table that is rare by nature. Scoped reads also scan outbound edges for
562
+ * the requested page, fetching content only for sources with hidden targets
563
+ * and re-resolving their own bracket text (vault#714). An unscoped reader
564
+ * does no extra work.
353
565
  */
354
- export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
566
+ export function getUnresolvedLinksForNotes(
567
+ db: Database,
568
+ noteIds: string[],
569
+ visible?: (noteId: string) => boolean,
570
+ ): Map<string, BrokenLink[]> {
355
571
  const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
356
572
  if (noteIds.length === 0) return result;
357
573
 
@@ -365,19 +581,117 @@ export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map
365
581
  ).all(...chunk) as typeof rows);
366
582
  }
367
583
  } catch {
368
- // Table doesn't exist — every id already maps to [] above.
369
- return result;
584
+ // Table doesn't exist — no note in this vault has ever had a link go
585
+ // unresolved. NOT an early return: a tag-scoped reader can still have
586
+ // broken references whose rows live in `ambiguous_wikilinks` (vault#239),
587
+ // and this is the common case for them — a vault where every collision
588
+ // was ambiguous vault-wide never creates `unresolved_wikilinks` at all.
589
+ rows.length = 0;
370
590
  }
371
591
 
592
+ const seen = new Set<string>();
372
593
  for (const row of rows) {
373
- result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
594
+ const relationship = row.relationship || WIKILINK_REL;
595
+ seen.add(`${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`);
596
+ result.get(row.source_id)?.push({ target: row.target_path, relationship });
597
+ }
598
+
599
+ // vault#239 — a target whose candidates are ALL invisible to this reader
600
+ // is broken in the reader's sub-vault, whichever table its row happens to
601
+ // live in right now. Without this the note only becomes "broken" once the
602
+ // last invisible candidate is DELETED and `refreshAmbiguousLinks` demotes
603
+ // the row into `unresolved_wikilinks` — a timing oracle for exactly the
604
+ // cross-scope naming collision `getAmbiguousLinksForNotes` narrows away.
605
+ // No-op (and no extra query) for an unscoped reader.
606
+ if (visible) {
607
+ for (const row of readAmbiguousRows(db, noteIds)) {
608
+ const relationship = row.relationship || WIKILINK_REL;
609
+ if (visibleResolutionCount(db, row.target_path, relationship, visible) > 0) continue;
610
+ const key = `${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`;
611
+ if (seen.has(key)) continue;
612
+ seen.add(key);
613
+ result.get(row.source_id)?.push({ target: row.target_path, relationship });
614
+ }
615
+
616
+ // vault#714: the persisted edge may resolve only to a hidden note.
617
+ // Reparse the source's own brackets, never the hidden target's path.
618
+ const hiddenTargetSources = new Set<string>();
619
+ for (const chunk of chunkForInClause(noteIds)) {
620
+ const placeholders = chunk.map(() => "?").join(", ");
621
+ const links = db.prepare(
622
+ `SELECT source_id, target_id FROM links WHERE source_id IN (${placeholders})`,
623
+ ).all(...chunk) as { source_id: string; target_id: string }[];
624
+ for (const link of links) {
625
+ if (!visible(link.target_id)) hiddenTargetSources.add(link.source_id);
626
+ }
627
+ }
628
+ for (const chunk of chunkForInClause([...hiddenTargetSources])) {
629
+ const placeholders = chunk.map(() => "?").join(", ");
630
+ const notes = db.prepare(
631
+ `SELECT id, content FROM notes WHERE id IN (${placeholders})`,
632
+ ).all(...chunk) as { id: string; content: string }[];
633
+ for (const note of notes) {
634
+ for (const { target } of parseWikilinks(note.content)) {
635
+ if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
636
+ const key = `${note.id}\u0000${WIKILINK_REL}\u0000${target.toLowerCase()}`;
637
+ if (seen.has(key)) continue;
638
+ seen.add(key);
639
+ result.get(note.id)?.push({ target, relationship: WIKILINK_REL });
640
+ }
641
+ }
642
+ }
374
643
  }
375
644
  return result;
376
645
  }
377
646
 
378
647
  /** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
379
- export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
380
- return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
648
+ export function getUnresolvedLinksForNote(
649
+ db: Database,
650
+ noteId: string,
651
+ visible?: (noteId: string) => boolean,
652
+ ): BrokenLink[] {
653
+ return getUnresolvedLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
654
+ }
655
+
656
+ /**
657
+ * Narrow a page of notes by the `has_broken_links` filter as a TAG-SCOPED
658
+ * reader should see it — the vault#239 twin of
659
+ * {@link narrowByVisibleAmbiguity}, and the reason
660
+ * {@link sqlHasBrokenLinks} lifts the SQL filter entirely under scope: the
661
+ * `unresolved_wikilinks` EXISTS test is neither a superset NOR a subset of
662
+ * the right answer (it MISSES a note whose only candidates are invisible,
663
+ * and it INCLUDES nothing it shouldn't), so neither polarity can be pushed
664
+ * down and both are re-decided here on the reader's own sub-vault.
665
+ *
666
+ * Page-shortening is the same effect `filterNotesByTagScope` already has on
667
+ * every scoped read. No-op when `wanted` is undefined or no predicate is
668
+ * injected (unscoped).
669
+ */
670
+ export function narrowByVisibleBrokenness<T extends { id: string }>(
671
+ db: Database,
672
+ notes: T[],
673
+ wanted: boolean | undefined,
674
+ visible: ((noteId: string) => boolean) | undefined,
675
+ ): T[] {
676
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
677
+ const byNote = getUnresolvedLinksForNotes(db, notes.map((n) => n.id), visible);
678
+ return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
679
+ }
680
+
681
+ /**
682
+ * The `hasBrokenLinks` value to push into SQL for a reader that will be
683
+ * narrowed by {@link narrowByVisibleBrokenness} afterwards. Unlike
684
+ * {@link sqlHasAmbiguousLinks} — where `true` survives because every
685
+ * truly-ambiguous note also has a persisted row — BOTH polarities are lifted
686
+ * under scope, because a note can be broken in the reader's sub-vault while
687
+ * having no `unresolved_wikilinks` row at all (vault#239). Identity for
688
+ * unscoped readers. Shared by both doors so REST and MCP cannot drift.
689
+ */
690
+ export function sqlHasBrokenLinks(
691
+ wanted: boolean | undefined,
692
+ scoped: boolean,
693
+ ): boolean | undefined {
694
+ return scoped ? undefined : wanted;
381
695
  }
382
696
 
383
697
  // ---------------------------------------------------------------------------
@@ -425,7 +739,10 @@ export interface AmbiguousWikilinkTarget {
425
739
  * THIRD same-named note rather than reporting the collision. The caller
426
740
  * (MCP `create-note`/`update-note`, REST `POST`/`PATCH /notes`) surfaces
427
741
  * `ambiguous` as an `ambiguous_link` warning naming the target + match
428
- * count, distinct from `unresolved`'s `unresolved_link`.
742
+ * count, distinct from `unresolved`'s `unresolved_link`. Each ambiguous
743
+ * target is ALSO persisted to `ambiguous_wikilinks` (vault#581) so the
744
+ * collision stays queryable after the write, symmetric with how
745
+ * `unresolved_wikilinks` backs `has_broken_links`/`include_broken_links`.
429
746
  */
430
747
  export function syncWikilinks(
431
748
  db: Database,
@@ -491,8 +808,13 @@ export function syncWikilinks(
491
808
  }
492
809
 
493
810
  // Store unresolved wikilinks for later resolution. Ambiguous targets are
494
- // deliberately NOT queued here — see the doc comment above.
811
+ // deliberately NOT queued into `unresolved_wikilinks` — see the doc comment
812
+ // above — but they ARE recorded in their own `ambiguous_wikilinks` table
813
+ // (vault#581) so a later audit can find them via
814
+ // `has_ambiguous_links`/`include_ambiguous_links` instead of only seeing
815
+ // the transient write-time warning.
495
816
  syncUnresolvedWikilinks(db, noteId, unresolved);
817
+ syncAmbiguousWikilinks(db, noteId, ambiguous);
496
818
 
497
819
  return { added, removed, unresolved, ambiguous };
498
820
  }
@@ -515,8 +837,15 @@ export function syncWikilinks(
515
837
  * already migrated. `PRAGMA table_info` on a nonexistent table returns zero
516
838
  * rows rather than throwing, so this is safe to call unconditionally,
517
839
  * including from read paths that don't want to create the table lazily.
840
+ *
841
+ * Also the body of `migrateToV28` (vault#567 item 2): boot runs this once
842
+ * via `initSchema` so a pre-#555 2-column table is healed before the first
843
+ * wikilink touch. Gated the existing way — no table → return; column
844
+ * present → return; only the 2-column shape rebuilds. Does NOT create the
845
+ * table on vaults that never queued an unresolved link (lazy creation
846
+ * stays). The per-touch calls below become a no-op after v28.
518
847
  */
519
- function ensureRelationshipColumn(db: Database): void {
848
+ export function ensureRelationshipColumn(db: Database): void {
520
849
  const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
521
850
  if (cols.length === 0) return; // table doesn't exist — nothing to heal
522
851
  if (cols.some((c) => c.name === "relationship")) return; // already migrated
@@ -746,6 +1075,515 @@ export function resolveUnresolvedWikilinks(
746
1075
  return resolved;
747
1076
  }
748
1077
 
1078
+
1079
+ // ---------------------------------------------------------------------------
1080
+ // Ambiguous links (vault#581) — the queryable twin of `unresolved_wikilinks`
1081
+ //
1082
+ // An ambiguous target (≥2 notes match) is deliberately never linked and never
1083
+ // queued for backfill (see {@link syncWikilinks}). Before #581 that meant it
1084
+ // existed ONLY in the create/update response's transient `ambiguous_link`
1085
+ // warning: `has_broken_links` didn't match the note (`unresolved_wikilinks`
1086
+ // held no row for it), so a later audit couldn't surface the collision at all
1087
+ // — asymmetric with the unresolved-link story. These helpers persist it in its
1088
+ // own lazily-created table, which backs the `has_ambiguous_links` /
1089
+ // `include_ambiguous_links` filters on `query-notes` / `GET /notes`.
1090
+ //
1091
+ // Kept in a SEPARATE table rather than folded into `unresolved_wikilinks` on
1092
+ // purpose: the two states mean different things to a caller ("nothing there
1093
+ // yet, will heal itself when the note arrives" vs. "too many things there,
1094
+ // disambiguate the reference"), the backfill sweep must never treat an
1095
+ // ambiguous row as pending-resolution, and folding them would have silently
1096
+ // widened what `has_broken_links: true` returns for every existing caller.
1097
+ //
1098
+ // Like `unresolved_wikilinks`, the table lives outside `SCHEMA_SQL` and is
1099
+ // created lazily on first write — a vault where no link has ever been
1100
+ // ambiguous never grows it, and no schema-version bump is needed.
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ /** One note's ambiguous outbound link, as surfaced on a note read (vault#581). */
1104
+ export interface AmbiguousLink {
1105
+ target: string;
1106
+ relationship: string;
1107
+ /** How many notes the target matched at the time it was last evaluated. */
1108
+ candidate_count: number;
1109
+ }
1110
+
1111
+ /**
1112
+ * Ensure the ambiguous_wikilinks table exists. Called lazily — only when we
1113
+ * actually have an ambiguous link. Same shape as `unresolved_wikilinks`
1114
+ * (3-column PK, `ON DELETE CASCADE` so deleting the SOURCE note drops its
1115
+ * rows) plus the match count the write-time warning already carries.
1116
+ */
1117
+ export function ensureAmbiguousTable(db: Database): void {
1118
+ db.exec(`
1119
+ CREATE TABLE IF NOT EXISTS ambiguous_wikilinks (
1120
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
1121
+ target_path TEXT NOT NULL COLLATE NOCASE,
1122
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
1123
+ candidate_count INTEGER NOT NULL DEFAULT 0,
1124
+ PRIMARY KEY (source_id, target_path, relationship)
1125
+ )
1126
+ `);
1127
+ }
1128
+
1129
+ /**
1130
+ * Replace this note's `relationship = "wikilink"` ambiguous rows with the
1131
+ * targets a fresh content parse just found ambiguous. Scoped to wikilink-kind
1132
+ * rows only, exactly like {@link syncUnresolvedWikilinks} — a content
1133
+ * re-parse must not clobber ambiguous STRUCTURED-link rows recorded for this
1134
+ * note by {@link queueAmbiguousLink} (a different relationship, same table).
1135
+ */
1136
+ function syncAmbiguousWikilinks(
1137
+ db: Database,
1138
+ noteId: string,
1139
+ ambiguous: AmbiguousWikilinkTarget[],
1140
+ ): void {
1141
+ if (ambiguous.length === 0) {
1142
+ // Clean up any old wikilink-kind ambiguous entries for this note (the
1143
+ // `[[Dup]]` was edited out, or one of the colliding notes went away and
1144
+ // the target now resolves).
1145
+ try {
1146
+ db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
1147
+ } catch {
1148
+ // Table may not exist yet — that's fine.
1149
+ }
1150
+ return;
1151
+ }
1152
+
1153
+ ensureAmbiguousTable(db);
1154
+ db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
1155
+ const insert = db.prepare(
1156
+ "INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
1157
+ );
1158
+ for (const entry of ambiguous) {
1159
+ insert.run(noteId, entry.target, WIKILINK_REL, entry.count);
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Record a structured link (or typed `reference` field value) whose target
1165
+ * matched ≥2 notes. `INSERT OR REPLACE` so a re-write with a different match
1166
+ * count refreshes rather than keeping a stale one. Called from
1167
+ * {@link resolveOrQueueLink}, the single funnel every structured-link write
1168
+ * path goes through.
1169
+ */
1170
+ export function queueAmbiguousLink(
1171
+ db: Database,
1172
+ sourceId: string,
1173
+ targetPath: string,
1174
+ relationship: string,
1175
+ candidateCount: number,
1176
+ ): void {
1177
+ ensureAmbiguousTable(db);
1178
+ db.prepare(
1179
+ "INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
1180
+ ).run(sourceId, targetPath, relationship, candidateCount);
1181
+ }
1182
+
1183
+ /**
1184
+ * Ambiguous-table twin of {@link clearQueuedLink} — drop every ambiguous row
1185
+ * for `sourceId` under `relationship`, whatever (stale) target it names. Used
1186
+ * by the scalar `reference`-field sync before re-resolving a changed value.
1187
+ * Safe no-op when the table doesn't exist yet.
1188
+ */
1189
+ export function clearAmbiguousLink(db: Database, sourceId: string, relationship: string): void {
1190
+ try {
1191
+ db.prepare(
1192
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?",
1193
+ ).run(sourceId, relationship);
1194
+ } catch {
1195
+ // Table may not exist yet — nothing to clear.
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * Ambiguous-table twin of {@link clearQueuedLinkTarget} — drop exactly ONE
1201
+ * row, scoped by `targetPath` as well, so a `cardinality: "many"` reference
1202
+ * field losing one element doesn't blanket-clear the others' rows.
1203
+ * Safe no-op when the table doesn't exist yet.
1204
+ */
1205
+ export function clearAmbiguousLinkTarget(
1206
+ db: Database,
1207
+ sourceId: string,
1208
+ relationship: string,
1209
+ targetPath: string,
1210
+ ): void {
1211
+ try {
1212
+ db.prepare(
1213
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
1214
+ ).run(sourceId, relationship, targetPath);
1215
+ } catch {
1216
+ // Table may not exist yet — nothing to clear.
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * Batch-fetch each note's ambiguous outbound links — the
1222
+ * `include_ambiguous_links` surfacing on `query-notes` / `GET /notes`. ONE
1223
+ * query for the whole page (mirrors {@link getUnresolvedLinksForNotes}), not
1224
+ * one per note. Every requested id gets an entry (possibly `[]`); when the
1225
+ * table has never been created every id maps to `[]` without a query attempt.
1226
+ *
1227
+ * `visible` (vault#581 auth review) is an OPTIONAL per-candidate-note
1228
+ * predicate, injected by the server layer for a TAG-SCOPED reader. Core
1229
+ * stays scope-unaware — it only invokes the closure — exactly like
1230
+ * `nearTraversable` / `expandVisibility` / `aggregateVisibility` on the MCP
1231
+ * tool layer.
1232
+ *
1233
+ * Why it's needed: `candidate_count` is derived VAULT-WIDE by
1234
+ * {@link resolveWikilinkDetailed}, so the stored `2` on a `[[Dup]]` whose
1235
+ * candidates are one `#work` and one `#personal` note tells a `work`-scoped
1236
+ * reader that a second `Dup` exists somewhere it cannot see. Unlike
1237
+ * `broken_links` — which is safe by construction, since "unresolved" can
1238
+ * only mean *matched nothing* and therefore can't fingerprint anything —
1239
+ * an ambiguous row exists ONLY because ≥2 notes matched, and the count
1240
+ * quantifies exactly that.
1241
+ *
1242
+ * So when `visible` is supplied, the persisted count is NOT trusted: each
1243
+ * row's target is re-resolved and its candidates narrowed to the ones the
1244
+ * reader can see. A row is reported only when ≥2 VISIBLE candidates remain,
1245
+ * and `candidate_count` is the visible count. That is precisely the answer
1246
+ * an unscoped reader would get on a vault containing only the visible notes
1247
+ * — the same "answer on the visible sub-vault" rule vault#674 (`.tags`
1248
+ * scrubbing) and vault#675 (out-of-scope query tags match nothing) chose,
1249
+ * rather than refusing the request. It also incidentally hides a row that
1250
+ * has gone stale (its target resolves cleanly again but no sweep has
1251
+ * touched it yet), since that too collapses to <2 candidates.
1252
+ *
1253
+ * Cost: one re-resolution per persisted row, and only for scoped readers.
1254
+ * Ambiguous rows are rare by nature (each is a genuine naming collision),
1255
+ * and a page with none does no extra work at all.
1256
+ */
1257
+ interface AmbiguousRow {
1258
+ source_id: string;
1259
+ target_path: string;
1260
+ relationship: string;
1261
+ candidate_count: number;
1262
+ }
1263
+
1264
+ /**
1265
+ * Read the persisted `ambiguous_wikilinks` rows for a set of source notes,
1266
+ * batched under the bound-param cap. `[]` when the table has never been
1267
+ * created. Shared by the ambiguity surface and the vault#239 brokenness
1268
+ * surface so the two read the same rows the same way.
1269
+ */
1270
+ function readAmbiguousRows(db: Database, noteIds: string[]): AmbiguousRow[] {
1271
+ const rows: AmbiguousRow[] = [];
1272
+ try {
1273
+ for (const chunk of chunkForInClause(noteIds)) {
1274
+ const placeholders = chunk.map(() => "?").join(", ");
1275
+ rows.push(...db.prepare(
1276
+ `SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE source_id IN (${placeholders})`,
1277
+ ).all(...chunk) as AmbiguousRow[]);
1278
+ }
1279
+ } catch {
1280
+ return []; // Table doesn't exist — nothing has ever been ambiguous here.
1281
+ }
1282
+ return rows;
1283
+ }
1284
+
1285
+ /**
1286
+ * How many notes this target resolves to IN THE READER'S OWN SUB-VAULT — the
1287
+ * single place both scoped link surfaces decide what a reference means for a
1288
+ * tag-scoped reader, so they cannot drift:
1289
+ *
1290
+ * - `>= 2` → ambiguous for this reader ({@link getAmbiguousLinksForNotes})
1291
+ * - `0` → broken for this reader ({@link getUnresolvedLinksForNotes})
1292
+ * - `1` → resolves cleanly; neither surface reports it
1293
+ *
1294
+ * A row that has gone stale (its target resolves to exactly one note again,
1295
+ * but no sweep has touched it yet) resolves through the SAME resolver its
1296
+ * write path used, so `resolved` has to be honoured explicitly — a resolved
1297
+ * `WikilinkResolution` carries an EMPTY `candidates` array, which would
1298
+ * otherwise read as "0 visible", i.e. broken.
1299
+ */
1300
+ function visibleResolutionCount(
1301
+ db: Database,
1302
+ targetPath: string,
1303
+ relationship: string,
1304
+ visible: (noteId: string) => boolean,
1305
+ ): number {
1306
+ const detail = relationship === WIKILINK_REL
1307
+ ? resolveWikilinkDetailed(db, targetPath)
1308
+ : resolveLinkTargetDetailed(db, targetPath);
1309
+ if (detail.resolved) return detail.note_id && visible(detail.note_id) ? 1 : 0;
1310
+ return detail.candidates.filter((c) => visible(c.note_id)).length;
1311
+ }
1312
+
1313
+ /**
1314
+ * Build the `ambiguous_link` write-time warning. ONE template per relationship
1315
+ * shape (content `[[wikilink]]` vs a structured `links` entry), shared by every
1316
+ * producer AND by {@link narrowLinkWarningsForVisibility}'s re-decision, so a
1317
+ * scoped caller's warning text can never drift from the unscoped one.
1318
+ */
1319
+ export function ambiguousLinkWarning(
1320
+ target: string,
1321
+ relationship: string,
1322
+ candidateCount: number,
1323
+ ): QueryWarning {
1324
+ return {
1325
+ code: "ambiguous_link",
1326
+ message: relationship === WIKILINK_REL
1327
+ ? `wikilink target "${target}" matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`
1328
+ : `link target "${target}" (relationship "${relationship}") matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1329
+ target,
1330
+ relationship,
1331
+ candidate_count: candidateCount,
1332
+ };
1333
+ }
1334
+
1335
+ /** Twin of {@link ambiguousLinkWarning} for the "matched nothing" outcome. */
1336
+ export function unresolvedLinkWarning(target: string, relationship: string): QueryWarning {
1337
+ return {
1338
+ code: "unresolved_link",
1339
+ message: relationship === WIKILINK_REL
1340
+ ? `wikilink target "${target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`
1341
+ : `link target "${target}" (relationship "${relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1342
+ target,
1343
+ relationship,
1344
+ };
1345
+ }
1346
+
1347
+ /**
1348
+ * WRITE-side twin of {@link getAmbiguousLinksForNotes}'s narrowing (vault#707).
1349
+ *
1350
+ * `create-note` / `update-note` echo a per-note `warnings` array, and an
1351
+ * `ambiguous_link` entry there carries a `candidate_count` derived VAULT-WIDE.
1352
+ * Without this, a `work`-scoped WRITER who saves a note containing `[[Dup]]`
1353
+ * learns from `candidate_count: 2` that a second `Dup` exists in a scope it
1354
+ * cannot see — the exact oracle vault#707 closed on the read side, reachable
1355
+ * through the write door instead.
1356
+ *
1357
+ * Same contract, same single decision function ({@link visibleResolutionCount}),
1358
+ * so the two doors cannot drift: a scoped writer gets exactly what an unscoped
1359
+ * writer would get on a vault containing only the notes it can see.
1360
+ *
1361
+ * - `>= 2` visible → keep, with `candidate_count` = the VISIBLE count
1362
+ * - `1` visible → not ambiguous in this sub-vault; drop the warning
1363
+ * - `0` visible → demote to `unresolved_link` (a plain broken link)
1364
+ *
1365
+ * Non-`ambiguous_link` warnings pass through untouched: `unresolved_link`
1366
+ * means "matched nothing", which by construction fingerprints nothing (the
1367
+ * same reasoning vault#707 applied to `include_broken_links`). Callers pass
1368
+ * `visible` ONLY for a tag-scoped session, so the unscoped response is
1369
+ * byte-identical — this function is never called there.
1370
+ */
1371
+ export function narrowLinkWarningsForVisibility(
1372
+ db: Database,
1373
+ warnings: QueryWarning[],
1374
+ visible: (noteId: string) => boolean,
1375
+ ): QueryWarning[] {
1376
+ const out: QueryWarning[] = [];
1377
+ for (const w of warnings) {
1378
+ if (w.code !== "ambiguous_link" || typeof w.target !== "string") {
1379
+ out.push(w);
1380
+ continue;
1381
+ }
1382
+ const relationship = typeof w.relationship === "string" ? w.relationship : WIKILINK_REL;
1383
+ const count = visibleResolutionCount(db, w.target, relationship, visible);
1384
+ if (count >= 2) out.push(ambiguousLinkWarning(w.target, relationship, count));
1385
+ else if (count === 0) out.push(unresolvedLinkWarning(w.target, relationship));
1386
+ // count === 1 → resolves cleanly in this sub-vault; report nothing.
1387
+ }
1388
+ return out;
1389
+ }
1390
+
1391
+ export function getAmbiguousLinksForNotes(
1392
+ db: Database,
1393
+ noteIds: string[],
1394
+ visible?: (noteId: string) => boolean,
1395
+ ): Map<string, AmbiguousLink[]> {
1396
+ const result = new Map<string, AmbiguousLink[]>(noteIds.map((id) => [id, []]));
1397
+ if (noteIds.length === 0) return result;
1398
+
1399
+ const rows = readAmbiguousRows(db, noteIds);
1400
+
1401
+ for (const row of rows) {
1402
+ const relationship = row.relationship || WIKILINK_REL;
1403
+ let candidateCount = row.candidate_count;
1404
+ if (visible) {
1405
+ candidateCount = visibleResolutionCount(db, row.target_path, relationship, visible);
1406
+ if (candidateCount < 2) continue; // not ambiguous in the reader's sub-vault
1407
+ }
1408
+ result.get(row.source_id)?.push({
1409
+ target: row.target_path,
1410
+ relationship,
1411
+ candidate_count: candidateCount,
1412
+ });
1413
+ }
1414
+ return result;
1415
+ }
1416
+
1417
+ /** Single-note convenience wrapper around {@link getAmbiguousLinksForNotes}. */
1418
+ export function getAmbiguousLinksForNote(
1419
+ db: Database,
1420
+ noteId: string,
1421
+ visible?: (noteId: string) => boolean,
1422
+ ): AmbiguousLink[] {
1423
+ return getAmbiguousLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
1424
+ }
1425
+
1426
+ /**
1427
+ * Narrow a page of notes by the vault#581 `has_ambiguous_links` filter as a
1428
+ * TAG-SCOPED reader should see it. Core's SQL filter counts a persisted row
1429
+ * regardless of whether the reader can see the notes that collided, so on
1430
+ * its own it is an oracle: a scoped caller could sweep its whole in-scope
1431
+ * corpus and enumerate cross-scope naming collisions.
1432
+ *
1433
+ * The server layer therefore asks core for a SUPERSET and applies the real
1434
+ * predicate here:
1435
+ * - `wanted === true` — the SQL filter is kept (every truly-ambiguous note
1436
+ * also has a row, so `EXISTS` is a superset) and this drops the notes
1437
+ * whose rows collapse to <2 visible candidates.
1438
+ * - `wanted === false` — the SQL filter is LIFTED (it would have excluded
1439
+ * notes that are not ambiguous in the reader's sub-vault, and a
1440
+ * post-filter cannot add rows back), and this keeps only notes with no
1441
+ * surviving row.
1442
+ *
1443
+ * Page-shortening is the same effect `filterNotesByTagScope` already has on
1444
+ * every scoped read — the page is narrowed after core drew it, so a scoped
1445
+ * page can come back shorter than `limit` while more results remain.
1446
+ * No-op when `wanted` is undefined or no predicate is injected (unscoped).
1447
+ */
1448
+ export function narrowByVisibleAmbiguity<T extends { id: string }>(
1449
+ db: Database,
1450
+ notes: T[],
1451
+ wanted: boolean | undefined,
1452
+ visible: ((noteId: string) => boolean) | undefined,
1453
+ ): T[] {
1454
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
1455
+ const byNote = getAmbiguousLinksForNotes(db, notes.map((n) => n.id), visible);
1456
+ return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
1457
+ }
1458
+
1459
+ /**
1460
+ * The `hasAmbiguousLinks` value to push into SQL for a reader that will be
1461
+ * narrowed by {@link narrowByVisibleAmbiguity} afterwards. `true` stays (it
1462
+ * is a superset); `false` is lifted to `undefined` (it is not). Identity for
1463
+ * unscoped readers. Shared by both doors so REST and MCP cannot drift on
1464
+ * which polarity is safe to push down.
1465
+ */
1466
+ export function sqlHasAmbiguousLinks(
1467
+ wanted: boolean | undefined,
1468
+ scoped: boolean,
1469
+ ): boolean | undefined {
1470
+ return scoped && wanted === false ? undefined : wanted;
1471
+ }
1472
+
1473
+ /**
1474
+ * Every string a `[[wikilink]]` / structured-link target could have used to
1475
+ * match `note` — its path, its basename, its `path.ext` form, and its H1
1476
+ * title: the four legs {@link resolveWikilinkDetailed} matches on, so this is
1477
+ * a complete necessary-condition superset. Lower-cased and trimmed. Shared by
1478
+ * {@link requeueInboundWikilinksForDelete}'s pre-filter and
1479
+ * {@link refreshAmbiguousLinks}'s scope so the two can't drift on what
1480
+ * "targets this note" means.
1481
+ */
1482
+ export function noteResolutionKeys(note: Pick<Note, "path" | "extension" | "content"> | null | undefined): string[] {
1483
+ const keys = new Set<string>();
1484
+ const addKey = (s: string | null | undefined): void => {
1485
+ const k = s?.trim().toLowerCase();
1486
+ if (k) keys.add(k);
1487
+ };
1488
+ if (note?.path) {
1489
+ addKey(note.path);
1490
+ const slash = note.path.lastIndexOf("/");
1491
+ addKey(slash >= 0 ? note.path.slice(slash + 1) : note.path); // basename
1492
+ if (note.extension) addKey(`${note.path}.${note.extension}`);
1493
+ }
1494
+ if (note?.content) addKey(extractH1Title(note.content));
1495
+ return [...keys];
1496
+ }
1497
+
1498
+ /** {@link noteResolutionKeys} for a bare path string (no note row to read) — used for a note's OLD path after a rename. */
1499
+ export function pathResolutionKeys(path: string | null | undefined, extension?: string | null): string[] {
1500
+ if (!path) return [];
1501
+ const keys = [path];
1502
+ const slash = path.lastIndexOf("/");
1503
+ keys.push(slash >= 0 ? path.slice(slash + 1) : path);
1504
+ if (extension) keys.push(`${path}.${extension}`);
1505
+ return keys.map((k) => k.trim().toLowerCase()).filter(Boolean);
1506
+ }
1507
+
1508
+ /**
1509
+ * Re-evaluate every persisted ambiguous row whose target is one of `keys` —
1510
+ * the self-healing counterpart of {@link resolveUnresolvedWikilinks}, and the
1511
+ * reason `has_ambiguous_links` can't go stale when the collision is cleaned
1512
+ * up. Called AFTER a note is created, deleted, or repathed, with that note's
1513
+ * {@link noteResolutionKeys} (plus the OLD path's keys on a rename), so the
1514
+ * scan is bounded to rows that note could possibly have been a candidate for.
1515
+ *
1516
+ * Each surviving row is re-run through the SAME resolver its write path used
1517
+ * (picked by `relationship`, exactly as the unresolved sweep does), and lands
1518
+ * in one of three places:
1519
+ *
1520
+ * - resolves to exactly one note now (a candidate was deleted or renamed
1521
+ * away) → the link is created and the row is dropped. The reference is
1522
+ * no longer ambiguous, so it must stop being reported as such.
1523
+ * - still ambiguous → the row stays; `candidate_count` is refreshed if the
1524
+ * number of colliding notes changed.
1525
+ * - matches nothing now (every candidate is gone) → the row moves to
1526
+ * `unresolved_wikilinks`, i.e. it becomes an ordinary BROKEN link that
1527
+ * `has_broken_links` reports and the normal backfill can later heal.
1528
+ *
1529
+ * Returns the number of rows changed. No-op (one bounded SELECT, or none at
1530
+ * all when the table has never been created) on a vault with no ambiguity.
1531
+ */
1532
+ export function refreshAmbiguousLinks(db: Database, keys: (string | null | undefined)[]): number {
1533
+ const normalized = [...new Set(
1534
+ keys.map((k) => k?.trim().toLowerCase()).filter((k): k is string => Boolean(k)),
1535
+ )];
1536
+ if (normalized.length === 0) return 0;
1537
+
1538
+ let rows: { source_id: string; target_path: string; relationship: string; candidate_count: number }[];
1539
+ try {
1540
+ // `keys` is at most a handful of strings (one note's resolution keys,
1541
+ // optionally plus an old path's), so this stays well under the bound-param
1542
+ // cap that `chunkForInClause` guards elsewhere.
1543
+ const clauses = normalized.map(() => "target_path = ? COLLATE NOCASE").join(" OR ");
1544
+ rows = db.prepare(
1545
+ `SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE ${clauses}`,
1546
+ ).all(...normalized) as typeof rows;
1547
+ } catch {
1548
+ return 0; // Table doesn't exist — nothing has ever been ambiguous here.
1549
+ }
1550
+ if (rows.length === 0) return 0;
1551
+
1552
+ const drop = db.prepare(
1553
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
1554
+ );
1555
+ const bumpCount = db.prepare(
1556
+ "UPDATE ambiguous_wikilinks SET candidate_count = ? WHERE source_id = ? AND target_path = ? AND relationship = ?",
1557
+ );
1558
+
1559
+ let changed = 0;
1560
+ for (const row of rows) {
1561
+ const relationship = row.relationship || WIKILINK_REL;
1562
+ const detail = relationship === WIKILINK_REL
1563
+ ? resolveWikilinkDetailed(db, row.target_path)
1564
+ : resolveLinkTargetDetailed(db, row.target_path);
1565
+
1566
+ if (detail.resolved) {
1567
+ // Wikilinks never self-link (write-time skips them), so guard here too.
1568
+ if (detail.note_id !== row.source_id) {
1569
+ linkOps.createLink(db, row.source_id, detail.note_id!, relationship);
1570
+ }
1571
+ drop.run(row.source_id, row.target_path, relationship);
1572
+ changed++;
1573
+ } else if (detail.ambiguous) {
1574
+ if (detail.candidates.length !== row.candidate_count) {
1575
+ bumpCount.run(detail.candidates.length, row.source_id, row.target_path, relationship);
1576
+ changed++;
1577
+ }
1578
+ } else {
1579
+ drop.run(row.source_id, row.target_path, relationship);
1580
+ queueUnresolvedLink(db, row.source_id, row.target_path, relationship);
1581
+ changed++;
1582
+ }
1583
+ }
1584
+ return changed;
1585
+ }
1586
+
749
1587
  // ---------------------------------------------------------------------------
750
1588
  // Structured links — same resolution + lazy forward-ref semantics as
751
1589
  // [[wikilinks]] (vault#555). A structured `links: [{target, relationship}]`
@@ -879,10 +1717,12 @@ export function clearQueuedLinkTarget(
879
1717
  * Returns a {@link ResolveOrQueueOutcome}:
880
1718
  * - `"resolved"` — the edge should be created against `note_id` now.
881
1719
  * - `"ambiguous"` (vault#570) — the target matched ≥2 notes (e.g. two
882
- * notes sharing an H1 title). NEITHER linked nor queued — see
883
- * {@link syncWikilinks}'s doc comment for why queuing an ambiguous
884
- * target would be wrong. Callers MUST surface a distinct
885
- * `ambiguous_link` warning naming the target + `candidates.length`.
1720
+ * notes sharing an H1 title). NEITHER linked nor queued for backfill —
1721
+ * see {@link syncWikilinks}'s doc comment for why queuing an ambiguous
1722
+ * target into `unresolved_wikilinks` would be wrong — but recorded in
1723
+ * `ambiguous_wikilinks` (vault#581) so it stays queryable after the
1724
+ * write. Callers MUST surface a distinct `ambiguous_link` warning
1725
+ * naming the target + `candidates.length`.
886
1726
  * - `"queued"` — the target matched NO note; queued for lazy backfill.
887
1727
  * Callers MUST surface an `unresolved_link` warning naming the target.
888
1728
  *
@@ -897,8 +1737,18 @@ export function resolveOrQueueLink(
897
1737
  relationship: string,
898
1738
  ): ResolveOrQueueOutcome {
899
1739
  const detail = resolveLinkTargetDetailed(db, target);
1740
+ if (detail.ambiguous) {
1741
+ // vault#581 — record the collision so it survives the response. Every
1742
+ // structured-link write path (MCP create/update-note, REST POST/PATCH,
1743
+ // typed `reference` fields) funnels through here, so persisting once
1744
+ // here covers all of them and can't drift from the warning.
1745
+ queueAmbiguousLink(db, sourceId, target, relationship, detail.candidates.length);
1746
+ return { status: "ambiguous", candidates: detail.candidates };
1747
+ }
1748
+ // Not ambiguous (any more): drop a stale row this exact (source, target,
1749
+ // relationship) may have left behind on an earlier write.
1750
+ clearAmbiguousLinkTarget(db, sourceId, relationship, target);
900
1751
  if (detail.resolved) return { status: "resolved", note_id: detail.note_id! };
901
- if (detail.ambiguous) return { status: "ambiguous", candidates: detail.candidates };
902
1752
  queueUnresolvedLink(db, sourceId, target, relationship);
903
1753
  return { status: "queued" };
904
1754
  }
@@ -932,20 +1782,9 @@ export function getContentWikilinkWarnings(
932
1782
  const detail = resolveWikilinkDetailed(db, wl.target);
933
1783
  if (detail.resolved) continue; // resolved (incl. self-link) — nothing to warn about
934
1784
  if (detail.ambiguous) {
935
- warnings.push({
936
- code: "ambiguous_link",
937
- message: `wikilink target "${wl.target}" matched ${detail.candidates.length} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`,
938
- target: wl.target,
939
- relationship: WIKILINK_REL,
940
- candidate_count: detail.candidates.length,
941
- });
1785
+ warnings.push(ambiguousLinkWarning(wl.target, WIKILINK_REL, detail.candidates.length));
942
1786
  } else {
943
- warnings.push({
944
- code: "unresolved_link",
945
- message: `wikilink target "${wl.target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
946
- target: wl.target,
947
- relationship: WIKILINK_REL,
948
- });
1787
+ warnings.push(unresolvedLinkWarning(wl.target, WIKILINK_REL));
949
1788
  }
950
1789
  }
951
1790
 
@@ -1005,25 +1844,15 @@ export function requeueInboundWikilinksForDelete(db: Database, noteId: string):
1005
1844
  // resolution keys — its path, basename, H1 title, or `path.ext` form (the
1006
1845
  // four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
1007
1846
  // target string equal to one of these, so the key set is a complete
1008
- // necessary-condition superset). Computed ONCE, then each source wikilink's
1009
- // target is gated on set membership BEFORE the expensive resolver call
1847
+ // necessary-condition superset — {@link noteResolutionKeys} owns that key
1848
+ // set, shared with the vault#581 ambiguity sweep). Computed ONCE, then each
1849
+ // source wikilink's target is gated on set membership BEFORE the resolver call
1010
1850
  // (whose title-fallback leg scans every note's content). Without this, a hub
1011
1851
  // note with hundreds of inbound sources would fire hundreds of full-vault
1012
1852
  // scans in one delete. The resolver still CONFIRMS each survivor — the
1013
1853
  // pre-filter narrows, it doesn't decide.
1014
1854
  const deleted = getNote(db, noteId);
1015
- const keys = new Set<string>();
1016
- const addKey = (s: string | null | undefined): void => {
1017
- const k = s?.trim().toLowerCase();
1018
- if (k) keys.add(k);
1019
- };
1020
- if (deleted?.path) {
1021
- addKey(deleted.path);
1022
- const slash = deleted.path.lastIndexOf("/");
1023
- addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
1024
- if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
1025
- }
1026
- if (deleted?.content) addKey(extractH1Title(deleted.content));
1855
+ const keys = new Set(noteResolutionKeys(deleted));
1027
1856
  if (keys.size === 0) return; // no key any wikilink could have matched on
1028
1857
 
1029
1858
  for (const { source_id } of inbound) {