@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
package/core/src/links.ts CHANGED
@@ -267,12 +267,16 @@ export function getLinksHydratedForNotes(
267
267
  * most two index scans regardless of page size. The IN-list is chunked to
268
268
  * stay under SQLite's bound-variable limit on very large pages.
269
269
  *
270
+ * With a visibility predicate, callers supply visible note ids and only rows
271
+ * whose other endpoint is visible count. The indexed selects return neighbour
272
+ * ids instead of grouped counts; absent the predicate, SQL is unchanged.
270
273
  * Returns 0 for ids with no links (every requested id is present in the map).
271
274
  */
272
275
  export function getLinkCounts(
273
276
  db: Database,
274
277
  noteIds: string[],
275
278
  direction: "both" | "outbound" | "inbound" = "both",
279
+ visible?: (noteId: string) => boolean,
276
280
  ): Map<string, number> {
277
281
  const counts = new Map<string, number>();
278
282
  if (noteIds.length === 0) return counts;
@@ -290,6 +294,28 @@ export function getLinkCounts(
290
294
  for (const chunk of chunkForInClause(ids)) {
291
295
  const placeholders = chunk.map(() => "?").join(", ");
292
296
 
297
+ // Scoped callers supply visible page-note ids. Count a row only when
298
+ // its other endpoint is visible too; a visible self-loop still adds 2.
299
+ if (visible) {
300
+ if (wantOutbound) {
301
+ const rows = db.prepare(
302
+ `SELECT source_id AS id, target_id AS other FROM links WHERE source_id IN (${placeholders})`,
303
+ ).all(...chunk) as { id: string; other: string }[];
304
+ for (const row of rows) {
305
+ if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
306
+ }
307
+ }
308
+ if (wantInbound) {
309
+ const rows = db.prepare(
310
+ `SELECT target_id AS id, source_id AS other FROM links WHERE target_id IN (${placeholders})`,
311
+ ).all(...chunk) as { id: string; other: string }[];
312
+ for (const row of rows) {
313
+ if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
314
+ }
315
+ }
316
+ continue;
317
+ }
318
+
293
319
  if (wantOutbound) {
294
320
  const rows = db.prepare(
295
321
  `SELECT source_id AS id, COUNT(*) AS c FROM links
@@ -314,6 +340,23 @@ export function getLinkCounts(
314
340
  return counts;
315
341
  }
316
342
 
343
+ /** Lift both has_links polarities from SQL under scope (vault#714). */
344
+ export function sqlHasLinks(wanted: boolean | undefined, scoped: boolean): boolean | undefined {
345
+ return scoped ? undefined : wanted;
346
+ }
347
+
348
+ /** Presence and degree share one oracle; narrowing can shorten a scoped page. */
349
+ export function narrowByVisibleLinks<T extends { id: string }>(
350
+ db: Database,
351
+ notes: T[],
352
+ wanted: boolean | undefined,
353
+ visible: ((noteId: string) => boolean) | undefined,
354
+ ): T[] {
355
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
356
+ const counts = getLinkCounts(db, notes.map((n) => n.id), "both", visible);
357
+ return notes.filter((n) => (counts.get(n.id)! > 0) === wanted);
358
+ }
359
+
317
360
  // ---- Deeper Link Queries ----
318
361
 
319
362
  export interface TraversalNode {
@@ -65,6 +65,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
65
65
 
66
66
  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\`.
67
67
 
68
+ Ambiguous links (vault#581): the twin of the above, for a target that matched TOO MANY notes rather than none — \`[[Dup]]\` when two notes share that basename or H1 title. No link is created and none is guessed at; before #581 that was visible only in the write-time \`ambiguous_link\` warning, so a later audit couldn't find it. Pass \`has_ambiguous_links: true\`/\`false\` to filter, and/or \`include_ambiguous_links: true\` to attach \`ambiguous_links: [{target, relationship, candidate_count}]\` (empty array when none). Disjoint from \`has_broken_links\`: dangling = matched nothing, ambiguous = matched several. The record is persisted, so it survives restarts — and it self-heals: delete or rename one of the colliding notes and the link resolves for real (the note drops out of \`has_ambiguous_links: true\`); delete them ALL and it demotes to an ordinary broken link. (For a tag-scoped session that demotion has already happened at read time — see \`has_broken_links\`.)
69
+
68
70
  Response shape (vault#550 — three variants, pick by what you passed):
69
71
  - Default (no \`cursor\`, no warnings): a bare array of notes.
70
72
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
@@ -119,8 +121,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
119
121
  description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
120
122
  },
121
123
  has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
122
- 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)." },
123
- 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)." },
124
+ 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). For a TAG-SCOPED session both endpoints must be visible. Both polarities are decided after the page is drawn, so a scoped page may be shorter than `limit` while more results remain; this filter does not participate in a scoped cursor query hash." },
125
+ 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). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous or a content wikilink resolves to an invisible note. Resolved structured links cannot recover the original caller string and are not named as broken — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
126
+ has_ambiguous_links: { type: "boolean", description: "Presence filter (vault#581): true = only notes with at least one AMBIGUOUS outbound link — a [[wikilink]] or structured `links` target that matched TWO OR MORE notes, so no link was created and none was guessed at; false = only notes with none. Disjoint from `has_broken_links` (dangling = matched nothing; ambiguous = matched too much). Backed by the ambiguous_wikilinks table — the same source `create-note`/`update-note`'s `ambiguous_link` warning draws from; safe on a vault where no link has ever been ambiguous (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see — a collision between a visible and an invisible note is neither reported by `true` nor excluded by `false` — so a scoped page may come back shorter than `limit` while more results remain." },
124
127
  path: { type: "string", description: "Exact path match (case-insensitive)" },
125
128
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
126
129
  exclude_path_prefix: {
@@ -173,7 +176,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
173
176
  last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
174
177
  created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
175
178
  last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
176
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for every note; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
179
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for unscoped both-direction counts. Under tag scope ordering still uses GLOBAL degree and may disagree with `linkCount`; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
177
180
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
178
181
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
179
182
  date_filter: {
@@ -237,17 +240,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
237
240
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
238
241
  },
239
242
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
240
- 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`." },
243
+ 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`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope, including a content wikilink resolved to an invisible note (vault#239, vault#714). Resolved structured links to invisible targets are suppressed but not named as broken: their original caller string is no longer stored, and the hidden target path must not be disclosed." },
244
+ include_ambiguous_links: { type: "boolean", description: "Include each note's ambiguous outbound links as `ambiguous_links: [{target, relationship, candidate_count}]` (default: false; vault#581). `target` is the path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string; `candidate_count` is how many notes it matched. For a TAG-SCOPED session the candidates are re-counted within the session's scope, and a target with fewer than two visible candidates is omitted — a scoped reader gets exactly what an unscoped one would get on a vault holding only the notes it can see. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_ambiguous_links` (same backing table) and `include_broken_links`." },
241
245
  include_link_count: {
242
246
  type: "boolean",
243
247
  description:
244
- "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
248
+ "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, only edges with BOTH endpoints visible count, in every direction; a visible self-loop still counts as 2. Scoped counts select neighbour ids over the same indexes; unscoped counts are unchanged.",
245
249
  },
246
250
  link_count_direction: {
247
251
  type: "string",
248
252
  enum: ["both", "outbound", "inbound"],
249
253
  description:
250
- "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
254
+ "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). For tag-scoped tokens only edges with both endpoints visible count in all three directions. order_by=link_count always uses GLOBAL both-directions degree, even under scope, and may disagree with linkCount.",
251
255
  },
252
256
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
253
257
  expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
package/core/src/mcp.ts CHANGED
@@ -23,7 +23,15 @@ import {
23
23
  resolveStructuredLinkNote,
24
24
  getUnresolvedLinksForNote,
25
25
  getUnresolvedLinksForNotes,
26
+ narrowByVisibleBrokenness,
27
+ sqlHasBrokenLinks,
28
+ getAmbiguousLinksForNote,
29
+ getAmbiguousLinksForNotes,
30
+ narrowByVisibleAmbiguity,
31
+ sqlHasAmbiguousLinks,
26
32
  getContentWikilinkWarnings,
33
+ ambiguousLinkWarning,
34
+ unresolvedLinkWarning,
27
35
  } from "./wikilinks.js";
28
36
  import * as tagSchemaOps from "./tag-schemas.js";
29
37
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -143,6 +151,17 @@ function structuredError(
143
151
  return Object.assign(new Error(message), fields);
144
152
  }
145
153
 
154
+ function requireNoteReference(value: unknown): string {
155
+ if (typeof value !== "string" || value.trim() === "") {
156
+ throw structuredError("`id` is required", {
157
+ error_type: "missing_required_field",
158
+ field: "id",
159
+ hint: "pass the note's id or path (or its unique H1 title)",
160
+ });
161
+ }
162
+ return value;
163
+ }
164
+
146
165
  /**
147
166
  * Resolve a note identifier — tries ID first, then case-insensitive
148
167
  * path match, then (additive fallback) an H1-title match. Works
@@ -470,6 +489,23 @@ export interface GenerateMcpToolsOpts {
470
489
  * path with no extra fetch.
471
490
  */
472
491
  aggregateVisibility?: (note: Note) => boolean;
492
+ /**
493
+ * `ambiguityVisible` (vault#581 auth review) is an OPTIONAL per-note
494
+ * predicate gating everything the ambiguous-links surface discloses.
495
+ * `candidate_count` is derived VAULT-WIDE, so a stored `2` on a `[[Dup]]`
496
+ * split across scopes tells a tag-scoped reader that a note it cannot see
497
+ * exists — and `has_ambiguous_links: true` would let it sweep its whole
498
+ * in-scope corpus for such collisions. When provided, each persisted row
499
+ * is re-resolved and its candidates narrowed to the visible ones: a row
500
+ * is disclosed only when ≥2 remain, `candidate_count` is the visible
501
+ * count, and `has_ambiguous_links` is answered against that same narrowed
502
+ * view (see `narrowByVisibleAmbiguity` / `sqlHasAmbiguousLinks` in
503
+ * core/src/wikilinks.ts). Same contract as `nearTraversable`: core stays
504
+ * scope-unaware and only invokes the injected `(noteId) => boolean`
505
+ * closure. Omitted (unscoped / internal callers) → the persisted counts
506
+ * are returned as-is and the SQL filter answers directly, unchanged.
507
+ */
508
+ ambiguityVisible?: (noteId: string) => boolean;
473
509
  /**
474
510
  * `AttachmentTicketProvider` seam (vault attachment-tickets design,
475
511
  * Wave 1 — D10 "tools omitted when unwired"). When provided,
@@ -549,6 +585,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
549
585
  const nearTraversable = opts?.nearTraversable;
550
586
  const ifExistsVisible = opts?.ifExistsVisible;
551
587
  const aggregateVisibility = opts?.aggregateVisibility;
588
+ const ambiguityVisible = opts?.ambiguityVisible;
552
589
  // Write-attribution (vault#298) — captured once at tool-generation time
553
590
  // (a fresh tool set is generated per MCP request, so this is request-scoped)
554
591
  // and folded into every create/update the tools perform.
@@ -600,6 +637,32 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
600
637
  {
601
638
  name: "query-notes",
602
639
  execute: async (params) => {
640
+ // --- Ambiguous-links scope split (vault#581 auth review) ---
641
+ // `requestedHasAmbiguous` is what the caller asked for;
642
+ // `sqlHasAmbiguous` is what is safe to push into SQL for a reader
643
+ // that will be narrowed by `narrowByVisibleAmbiguity` afterwards
644
+ // (`true` is a superset and stays; `false` is lifted). Identical for
645
+ // an unscoped reader, where no predicate is injected and the SQL
646
+ // filter alone is the whole answer.
647
+ const requestedHasAmbiguous = params.has_ambiguous_links as boolean | undefined;
648
+ const sqlHasAmbiguous = sqlHasAmbiguousLinks(requestedHasAmbiguous, Boolean(ambiguityVisible));
649
+
650
+ // --- Broken-links scope split (vault#239) ---
651
+ // Same shape as the ambiguity split above, one polarity stricter:
652
+ // NEITHER `true` nor `false` is safe to push into SQL for a scoped
653
+ // reader, because a note can be broken in that reader's sub-vault
654
+ // while carrying no `unresolved_wikilinks` row at all (its target's
655
+ // only candidates are invisible, so the row sits in
656
+ // `ambiguous_wikilinks`). `sqlHasBrokenLinks` lifts both and
657
+ // `narrowByVisibleBrokenness` re-decides per note. Identical for an
658
+ // unscoped reader, where the SQL filter alone is the whole answer.
659
+ const requestedHasBroken = params.has_broken_links as boolean | undefined;
660
+ const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
661
+
662
+ // vault#714: degree and presence use the same visible-edge count.
663
+ const requestedHasLinks = params.has_links as boolean | undefined;
664
+ const sqlLinks = linkOps.sqlHasLinks(requestedHasLinks, Boolean(ambiguityVisible));
665
+
603
666
  // --- Link expansion config (shared across single + list paths) ---
604
667
  const expandLinks = params.expand_links === true;
605
668
  const expandMode = (params.expand_mode as ExpandMode) ?? "full";
@@ -671,7 +734,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
671
734
  result.links = linkOps.getLinksHydrated(db, note.id);
672
735
  }
673
736
  if (params.include_broken_links) {
674
- result.broken_links = getUnresolvedLinksForNote(db, note.id);
737
+ result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisible);
738
+ }
739
+ if (params.include_ambiguous_links) {
740
+ result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisible);
675
741
  }
676
742
  if (params.include_attachments) {
677
743
  result.attachments = await store.getAttachments(note.id);
@@ -680,7 +746,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
680
746
  // links/attachments above; filterMetadata only touches `metadata`.
681
747
  if (params.include_link_count) {
682
748
  const dir = normalizeLinkCountDirection(params.link_count_direction);
683
- result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
749
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], dir, ambiguityVisible).get(note.id) ?? 0;
684
750
  }
685
751
  return result;
686
752
  }
@@ -815,8 +881,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
815
881
  expand,
816
882
  excludeTags: aggExcludeTags,
817
883
  hasTags: params.has_tags as boolean | undefined,
818
- hasLinks: params.has_links as boolean | undefined,
819
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
884
+ hasLinks: sqlLinks,
885
+ hasBrokenLinks: sqlHasBroken,
886
+ hasAmbiguousLinks: sqlHasAmbiguous,
820
887
  path: params.path as string | undefined,
821
888
  pathPrefix: params.path_prefix as string | undefined,
822
889
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -842,7 +909,28 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
842
909
  // set (reusing the `ids` semijoin `near` already pushes into SQL).
843
910
  // Core stays scope-unaware — it only invokes the plain closure.
844
911
  const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
845
- const aggVisibleIds = aggAllMatches.filter(aggregateVisibility).map((n) => n.id);
912
+ // vault#581 auth review: a rollup over `has_ambiguous_links` is the
913
+ // same oracle as the note-level filter (a non-zero count still
914
+ // reveals the collision), so narrow the visible id set by the
915
+ // reader's own view of each row before aggregating.
916
+ // vault#239: the `has_broken_links` rollup is the same oracle for
917
+ // the same reason — re-decide brokenness on the sub-vault too.
918
+ const aggVisibleIds = linkOps.narrowByVisibleLinks(
919
+ db,
920
+ narrowByVisibleBrokenness(
921
+ db,
922
+ narrowByVisibleAmbiguity(
923
+ db,
924
+ aggAllMatches.filter(aggregateVisibility),
925
+ requestedHasAmbiguous,
926
+ ambiguityVisible,
927
+ ),
928
+ requestedHasBroken,
929
+ ambiguityVisible,
930
+ ),
931
+ requestedHasLinks,
932
+ ambiguityVisible,
933
+ ).map((n) => n.id);
846
934
  // Always run the rollup, even on an empty visible set: ungrouped
847
935
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
848
936
  return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
@@ -938,8 +1026,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
938
1026
  expand,
939
1027
  excludeTags,
940
1028
  hasTags: params.has_tags as boolean | undefined,
941
- hasLinks: params.has_links as boolean | undefined,
942
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1029
+ hasLinks: sqlLinks,
1030
+ hasBrokenLinks: sqlHasBroken,
1031
+ hasAmbiguousLinks: sqlHasAmbiguous,
943
1032
  path: params.path as string | undefined,
944
1033
  pathPrefix: params.path_prefix as string | undefined,
945
1034
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1021,8 +1110,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1021
1110
  expand,
1022
1111
  excludeTags,
1023
1112
  hasTags: params.has_tags as boolean | undefined,
1024
- hasLinks: params.has_links as boolean | undefined,
1025
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1113
+ hasLinks: sqlLinks,
1114
+ hasBrokenLinks: sqlHasBroken,
1115
+ hasAmbiguousLinks: sqlHasAmbiguous,
1026
1116
  path: params.path as string | undefined,
1027
1117
  pathPrefix: params.path_prefix as string | undefined,
1028
1118
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1090,8 +1180,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1090
1180
  expand,
1091
1181
  excludeTags,
1092
1182
  hasTags: params.has_tags as boolean | undefined,
1093
- hasLinks: params.has_links as boolean | undefined,
1094
- hasBrokenLinks: params.has_broken_links as boolean | undefined,
1183
+ hasLinks: sqlLinks,
1184
+ hasBrokenLinks: sqlHasBroken,
1185
+ hasAmbiguousLinks: sqlHasAmbiguous,
1095
1186
  path: params.path as string | undefined,
1096
1187
  pathPrefix: params.path_prefix as string | undefined,
1097
1188
  excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
@@ -1148,6 +1239,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1148
1239
  results = results.filter((n) => nearScope!.has(n.id));
1149
1240
  }
1150
1241
 
1242
+ // vault#581 auth review — the `has_ambiguous_links` filter must
1243
+ // answer on the reader's OWN sub-vault, not the whole vault. No-op
1244
+ // unscoped (no predicate injected) and when the filter wasn't asked
1245
+ // for. See `narrowByVisibleAmbiguity` for the superset contract and
1246
+ // the page-shortening effect.
1247
+ results = narrowByVisibleAmbiguity(db, results, requestedHasAmbiguous, ambiguityVisible);
1248
+ // vault#239 — same rule for `has_broken_links`: the SQL filter was
1249
+ // lifted for a scoped reader, so the real predicate is applied here
1250
+ // on that reader's own sub-vault. No-op unscoped.
1251
+ results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
1252
+ results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
1253
+
1151
1254
  // --- Format output ---
1152
1255
  const includeContent = params.include_content === true; // default false for list
1153
1256
  // Range params require content in the response — on lists that
@@ -1214,12 +1317,12 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1214
1317
  // survives. Don't casually swap the order.
1215
1318
  if (params.include_link_count) {
1216
1319
  const dir = normalizeLinkCountDirection(params.link_count_direction);
1217
- const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
1320
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir, ambiguityVisible);
1218
1321
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1219
1322
  }
1220
1323
 
1221
1324
  // --- Hydrate links/attachments/broken-links per note if requested ---
1222
- if (params.include_links || params.include_attachments || params.include_broken_links) {
1325
+ if (params.include_links || params.include_attachments || params.include_broken_links || params.include_ambiguous_links) {
1223
1326
  // Links hydrate for the WHOLE page in a constant number of
1224
1327
  // queries (see getLinksHydratedForNotes) — the per-note variant
1225
1328
  // cost (1 link query + 1 summary query + N tag queries) × page
@@ -1229,13 +1332,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1229
1332
  : null;
1230
1333
  // Same one-batched-query-for-the-page shape as links (vault#555).
1231
1334
  const brokenLinksByNote = params.include_broken_links
1232
- ? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
1335
+ ? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
1336
+ : null;
1337
+ // Same one-batched-query-for-the-page shape for the ambiguity twin (vault#581).
1338
+ const ambiguousLinksByNote = params.include_ambiguous_links
1339
+ ? getAmbiguousLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
1233
1340
  : null;
1234
1341
  const enrichedOut: any[] = [];
1235
1342
  for (const n of output as any[]) {
1236
1343
  const enriched: any = { ...n };
1237
1344
  if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
1238
1345
  if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
1346
+ if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
1239
1347
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
1240
1348
  enrichedOut.push(enriched);
1241
1349
  }
@@ -1559,20 +1667,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1559
1667
  if (outcome.status === "resolved") {
1560
1668
  await store.createLink(sourceId, outcome.note_id, link.relationship);
1561
1669
  } else if (outcome.status === "ambiguous") {
1562
- pushLinkWarning(sourceId, {
1563
- code: "ambiguous_link",
1564
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1565
- target: link.target,
1566
- relationship: link.relationship,
1567
- candidate_count: outcome.candidates.length,
1568
- });
1670
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
1569
1671
  } else {
1570
- pushLinkWarning(sourceId, {
1571
- code: "unresolved_link",
1572
- 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.`,
1573
- target: link.target,
1574
- relationship: link.relationship,
1575
- });
1672
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
1576
1673
  }
1577
1674
  }
1578
1675
  }
@@ -1730,7 +1827,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1730
1827
  // branch using this same item's payload. Otherwise mirror the
1731
1828
  // existing `requireNote` behavior (throw "Note not found").
1732
1829
  // vault#309.
1733
- const resolved = resolveNote(db, item.id as string);
1830
+ const idOrPath = requireNoteReference(item.id);
1831
+ const resolved = resolveNote(db, idOrPath);
1734
1832
  if (!resolved) {
1735
1833
  if (item.if_missing === "create") {
1736
1834
  // Treat the update payload as a create payload. Minimum:
@@ -1765,7 +1863,6 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1765
1863
  // processed and used by Gitcoin's sync; the
1766
1864
  // misleading wording is fixed here so a future
1767
1865
  // reader doesn't trust it and break the workflow.
1768
- const idOrPath = item.id as string;
1769
1866
  // Heuristic: if `path` isn't set AND the `id` looks like a
1770
1867
  // path (contains "/" or doesn't match a typical opaque-id
1771
1868
  // shape), use it as the path too. Otherwise treat it as a
@@ -2118,20 +2215,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2118
2215
  if (outcome.status === "resolved") {
2119
2216
  await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
2120
2217
  } else if (outcome.status === "ambiguous") {
2121
- pushLinkWarning(sourceId, {
2122
- code: "ambiguous_link",
2123
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2124
- target: link.target,
2125
- relationship: link.relationship,
2126
- candidate_count: outcome.candidates.length,
2127
- });
2218
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2128
2219
  } else {
2129
- pushLinkWarning(sourceId, {
2130
- code: "unresolved_link",
2131
- 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.`,
2132
- target: link.target,
2133
- relationship: link.relationship,
2134
- });
2220
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
2135
2221
  }
2136
2222
  }
2137
2223
  }
@@ -2193,7 +2279,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
2193
2279
  {
2194
2280
  name: "delete-note",
2195
2281
  execute: async (params) => {
2196
- const note = requireNote(db, params.id as string);
2282
+ const note = requireNote(db, requireNoteReference(params.id));
2197
2283
  await store.deleteNote(note.id);
2198
2284
  return { deleted: true, id: note.id };
2199
2285
  },
@@ -3032,4 +3118,3 @@ export class BatchTooLargeError extends Error {
3032
3118
  this.got = got;
3033
3119
  }
3034
3120
  }
3035
-
package/core/src/notes.ts CHANGED
@@ -1053,8 +1053,9 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
1053
1053
  // Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
1054
1054
  // structured `links` target that never resolved. The `unresolved_wikilinks`
1055
1055
  // table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
1056
- // a link actually goes unresolved — a vault where nothing ever has won't
1057
- // have the table at all. Check existence first rather than reference it
1056
+ // a link actually goes unresolved — migrateToV28 heals a pre-#555 2-column
1057
+ // table at boot but does NOT create the table on a vault that never queued
1058
+ // one. Check existence first rather than reference it
1058
1059
  // unconditionally: a read-only query filter shouldn't have the side effect
1059
1060
  // of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
1060
1061
  // table would throw "no such table" instead of the correct empty answer.
@@ -1076,6 +1077,28 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
1076
1077
  }
1077
1078
  }
1078
1079
 
1080
+ // Presence: has_ambiguous_links (vault#581) — an outbound `[[wikilink]]` or
1081
+ // structured `links`/`reference` target that matched ≥2 notes, so no link
1082
+ // was created. Same lazily-created-table dance as `has_broken_links` above
1083
+ // (`ambiguous_wikilinks` is only created once a link actually goes
1084
+ // ambiguous), and for the same reasons: a read-only filter must not create
1085
+ // the table, and a bare EXISTS against a missing one throws instead of
1086
+ // answering "none".
1087
+ if (opts.hasAmbiguousLinks !== undefined) {
1088
+ const ambiguousTableExists = db.prepare(
1089
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ambiguous_wikilinks'",
1090
+ ).get() !== null;
1091
+ if (!ambiguousTableExists) {
1092
+ if (opts.hasAmbiguousLinks) conditions.push("0 = 1");
1093
+ } else {
1094
+ conditions.push(
1095
+ opts.hasAmbiguousLinks
1096
+ ? `EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`
1097
+ : `NOT EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`,
1098
+ );
1099
+ }
1100
+ }
1101
+
1079
1102
  // ID set filter — used by `near` to push neighborhood scoping into SQL so
1080
1103
  // that LIMIT applies to the neighborhood, not the whole notes table.
1081
1104
  if (opts.ids !== undefined) {
@@ -1694,6 +1717,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
1694
1717
  hasTags: opts.hasTags,
1695
1718
  hasLinks: opts.hasLinks,
1696
1719
  hasBrokenLinks: opts.hasBrokenLinks,
1720
+ hasAmbiguousLinks: opts.hasAmbiguousLinks,
1697
1721
  path: opts.path,
1698
1722
  pathPrefix: opts.pathPrefix,
1699
1723
  excludePathPrefix: opts.excludePathPrefix,
@@ -2767,6 +2791,8 @@ export function mergeTags(
2767
2791
  const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
2768
2792
  const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
2769
2793
  const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
2794
+ const sourceNoteIdsStmt = db.prepare("SELECT note_id FROM note_tags WHERE tag_name = ?");
2795
+ const affectedIds = new Set<string>();
2770
2796
 
2771
2797
  for (const source of uniqueSources) {
2772
2798
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
@@ -2775,6 +2801,11 @@ export function mergeTags(
2775
2801
  continue;
2776
2802
  }
2777
2803
  const before = (countStmt.get(source) as { c: number }).c;
2804
+ // Collect BEFORE the delete so we can bump updated_at on every note
2805
+ // whose tags actually change (vault#567).
2806
+ for (const row of sourceNoteIdsStmt.all(source) as { note_id: string }[]) {
2807
+ affectedIds.add(row.note_id);
2808
+ }
2778
2809
  retagStmt.run(target, source);
2779
2810
  deleteNoteTagsStmt.run(source);
2780
2811
  // Dropping the tag row drops its identity (description, fields,
@@ -2783,6 +2814,24 @@ export function mergeTags(
2783
2814
  deleteTagStmt.run(source);
2784
2815
  merged[source] = before;
2785
2816
  }
2817
+
2818
+ // vault#567: a tags-only `update-note` already bumps `updated_at` so
2819
+ // cursor/sync consumers see the retag. `merge-tags` used to skip the
2820
+ // bump (flood-avoidance), which made the equivalent bulk retag
2821
+ // invisible to since-last-check loops. Bump every note that actually
2822
+ // lost a source tag; notes that never carried a merged-away source
2823
+ // are left untouched. `updated_at_ms` moves with `updated_at`
2824
+ // (vault#586) so the cursor keyset surfaces the change.
2825
+ if (affectedIds.size > 0) {
2826
+ const now = new Date().toISOString();
2827
+ const nowMs = timestampToMs(now) ?? Date.now();
2828
+ const bumpStmt = db.prepare(
2829
+ "UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?",
2830
+ );
2831
+ for (const id of affectedIds) {
2832
+ bumpStmt.run(now, nowMs, id);
2833
+ }
2834
+ }
2786
2835
  });
2787
2836
 
2788
2837
  return { merged, target };