@openparachute/vault 0.7.9-rc.3 → 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.
@@ -299,3 +299,32 @@ describe("query-notes MCP surface: include_link_count", () => {
299
299
  for (const n of ordered) expect(typeof n.linkCount).toBe("number");
300
300
  });
301
301
  });
302
+
303
+
304
+ describe("vault#714 scoped link degree", () => {
305
+ async function seedScope() {
306
+ for (const id of ["a", "b", "hidden"]) await store.createNote(id, { id });
307
+ await store.createLink("a", "b", "visible");
308
+ await store.createLink("a", "hidden", "hidden-out");
309
+ await store.createLink("hidden", "a", "hidden-in");
310
+ }
311
+ for (const [direction, expected] of [["both", 1], ["outbound", 1], ["inbound", 0]] as const) {
312
+ it(`vault#714 scoped ${direction}`, async () => {
313
+ await seedScope();
314
+ expect(getLinkCounts(db, ["a"], direction, (id) => id !== "hidden").get("a")).toBe(expected);
315
+ });
316
+ }
317
+ it("vault#714 visible self-loop remains degree two", async () => {
318
+ await seedScope(); await store.createLink("b", "b", "self");
319
+ expect(getLinkCounts(db, ["b"], "both", (id) => id === "b").get("b")).toBe(2);
320
+ });
321
+ it("vault#714 absent predicate preserves global degree", async () => {
322
+ await seedScope();
323
+ expect(getLinkCounts(db, ["a"], "both")).toEqual(new Map([["a", 3]]));
324
+ expect(getLinkCounts(db, ["a"], "both", undefined)).toEqual(getLinkCounts(db, ["a"], "both"));
325
+ });
326
+ it("vault#714 repeated ids and typed rows retain row-count semantics", async () => {
327
+ await seedScope(); await store.createLink("a", "b", "second");
328
+ expect(getLinkCounts(db, ["a", "a"], "both", (id) => id !== "hidden").get("a")).toBe(2);
329
+ });
330
+ });
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 {
@@ -121,8 +121,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
121
121
  description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
122
122
  },
123
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." },
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)." },
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 — 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." },
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
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." },
127
127
  path: { type: "string", description: "Exact path match (case-insensitive)" },
128
128
  path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
@@ -176,7 +176,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
176
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." },
177
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." },
178
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." },
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 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." },
180
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 }`." },
181
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 }`." },
182
182
  date_filter: {
@@ -240,18 +240,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
240
240
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
241
241
  },
242
242
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
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, which is broken in that session's sub-vault (vault#239)." },
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
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`." },
245
245
  include_link_count: {
246
246
  type: "boolean",
247
247
  description:
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, `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.",
249
249
  },
250
250
  link_count_direction: {
251
251
  type: "string",
252
252
  enum: ["both", "outbound", "inbound"],
253
253
  description:
254
- "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.",
255
255
  },
256
256
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
257
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
@@ -659,6 +659,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
659
659
  const requestedHasBroken = params.has_broken_links as boolean | undefined;
660
660
  const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
661
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
+
662
666
  // --- Link expansion config (shared across single + list paths) ---
663
667
  const expandLinks = params.expand_links === true;
664
668
  const expandMode = (params.expand_mode as ExpandMode) ?? "full";
@@ -742,7 +746,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
742
746
  // links/attachments above; filterMetadata only touches `metadata`.
743
747
  if (params.include_link_count) {
744
748
  const dir = normalizeLinkCountDirection(params.link_count_direction);
745
- 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;
746
750
  }
747
751
  return result;
748
752
  }
@@ -877,7 +881,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
877
881
  expand,
878
882
  excludeTags: aggExcludeTags,
879
883
  hasTags: params.has_tags as boolean | undefined,
880
- hasLinks: params.has_links as boolean | undefined,
884
+ hasLinks: sqlLinks,
881
885
  hasBrokenLinks: sqlHasBroken,
882
886
  hasAmbiguousLinks: sqlHasAmbiguous,
883
887
  path: params.path as string | undefined,
@@ -911,15 +915,20 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
911
915
  // reader's own view of each row before aggregating.
912
916
  // vault#239: the `has_broken_links` rollup is the same oracle for
913
917
  // the same reason — re-decide brokenness on the sub-vault too.
914
- const aggVisibleIds = narrowByVisibleBrokenness(
918
+ const aggVisibleIds = linkOps.narrowByVisibleLinks(
915
919
  db,
916
- narrowByVisibleAmbiguity(
920
+ narrowByVisibleBrokenness(
917
921
  db,
918
- aggAllMatches.filter(aggregateVisibility),
919
- requestedHasAmbiguous,
922
+ narrowByVisibleAmbiguity(
923
+ db,
924
+ aggAllMatches.filter(aggregateVisibility),
925
+ requestedHasAmbiguous,
926
+ ambiguityVisible,
927
+ ),
928
+ requestedHasBroken,
920
929
  ambiguityVisible,
921
930
  ),
922
- requestedHasBroken,
931
+ requestedHasLinks,
923
932
  ambiguityVisible,
924
933
  ).map((n) => n.id);
925
934
  // Always run the rollup, even on an empty visible set: ungrouped
@@ -1017,7 +1026,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1017
1026
  expand,
1018
1027
  excludeTags,
1019
1028
  hasTags: params.has_tags as boolean | undefined,
1020
- hasLinks: params.has_links as boolean | undefined,
1029
+ hasLinks: sqlLinks,
1021
1030
  hasBrokenLinks: sqlHasBroken,
1022
1031
  hasAmbiguousLinks: sqlHasAmbiguous,
1023
1032
  path: params.path as string | undefined,
@@ -1101,7 +1110,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1101
1110
  expand,
1102
1111
  excludeTags,
1103
1112
  hasTags: params.has_tags as boolean | undefined,
1104
- hasLinks: params.has_links as boolean | undefined,
1113
+ hasLinks: sqlLinks,
1105
1114
  hasBrokenLinks: sqlHasBroken,
1106
1115
  hasAmbiguousLinks: sqlHasAmbiguous,
1107
1116
  path: params.path as string | undefined,
@@ -1171,7 +1180,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1171
1180
  expand,
1172
1181
  excludeTags,
1173
1182
  hasTags: params.has_tags as boolean | undefined,
1174
- hasLinks: params.has_links as boolean | undefined,
1183
+ hasLinks: sqlLinks,
1175
1184
  hasBrokenLinks: sqlHasBroken,
1176
1185
  hasAmbiguousLinks: sqlHasAmbiguous,
1177
1186
  path: params.path as string | undefined,
@@ -1240,6 +1249,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1240
1249
  // lifted for a scoped reader, so the real predicate is applied here
1241
1250
  // on that reader's own sub-vault. No-op unscoped.
1242
1251
  results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
1252
+ results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
1243
1253
 
1244
1254
  // --- Format output ---
1245
1255
  const includeContent = params.include_content === true; // default false for list
@@ -1307,7 +1317,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
1307
1317
  // survives. Don't casually swap the order.
1308
1318
  if (params.include_link_count) {
1309
1319
  const dir = normalizeLinkCountDirection(params.link_count_direction);
1310
- 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);
1311
1321
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1312
1322
  }
1313
1323
 
@@ -492,6 +492,42 @@ export function listZeroVisibleAmbiguousAsUnresolved(
492
492
  return out;
493
493
  }
494
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
+
495
531
  /** One note's dangling outbound link, as surfaced on a note read (vault#555). */
496
532
  export interface BrokenLink {
497
533
  target: string;
@@ -522,7 +558,10 @@ export interface BrokenLink {
522
558
  *
523
559
  * Cost: one extra query plus one re-resolution per ambiguous row, and only
524
560
  * for scoped readers — the same profile the ambiguity surface accepts, on a
525
- * table that is rare by nature. An unscoped reader does no extra work.
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.
526
565
  */
527
566
  export function getUnresolvedLinksForNotes(
528
567
  db: Database,
@@ -573,6 +612,34 @@ export function getUnresolvedLinksForNotes(
573
612
  seen.add(key);
574
613
  result.get(row.source_id)?.push({ target: row.target_path, relationship });
575
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
+ }
576
643
  }
577
644
  return result;
578
645
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.3",
3
+ "version": "0.7.9-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -11,8 +11,10 @@
11
11
  import { describe, expect, test } from "bun:test";
12
12
  import {
13
13
  compareVersions,
14
+ coreVersion,
14
15
  decidePublish,
15
16
  distTagFor,
17
+ matchingRcVersions,
16
18
  readRegistry,
17
19
  unpublishedDrift,
18
20
  } from "../scripts/release-plan.ts";
@@ -193,13 +195,81 @@ describe("decidePublish", () => {
193
195
  {
194
196
  versionExists: false,
195
197
  currentDistTagVersion: "0.7.8",
196
- publishedVersions: ["0.7.8"],
198
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
197
199
  },
198
200
  { branch: "main" },
199
201
  );
200
202
  expect(d).toMatchObject({ publish: true });
201
203
  });
202
204
 
205
+ test("a stable without a matching rc is refused — vault#697", () => {
206
+ // Hub 0.7.13–0.7.16 shipped @latest with no rc of the same X.Y.Z.
207
+ // Vault's previous plan (and the inline shell it replaced) did the same.
208
+ const d = decidePublish(
209
+ "0.7.9",
210
+ {
211
+ versionExists: false,
212
+ currentDistTagVersion: "0.7.8",
213
+ publishedVersions: ["0.7.8", "0.7.8-rc.2"],
214
+ },
215
+ { branch: "main" },
216
+ );
217
+ expect(d).toMatchObject({ refuse: true });
218
+ expect("refuse" in d && d.reason).toMatch(/0\.7\.9-rc/);
219
+ expect("refuse" in d && d.reason).toMatch(/suffix-drop|Cut an rc first/i);
220
+ });
221
+
222
+ test("a stable whose only published rcs are a different X.Y.Z is still refused", () => {
223
+ const d = decidePublish(
224
+ "0.7.9",
225
+ {
226
+ versionExists: false,
227
+ currentDistTagVersion: "0.7.8",
228
+ publishedVersions: ["0.7.8", "0.7.8-rc.1"],
229
+ },
230
+ { branch: "main" },
231
+ );
232
+ expect(d).toMatchObject({ refuse: true });
233
+ });
234
+
235
+ test("a stable with a matching rc publishes from main", () => {
236
+ const d = decidePublish(
237
+ "0.7.9",
238
+ {
239
+ versionExists: false,
240
+ currentDistTagVersion: "0.7.8",
241
+ publishedVersions: ["0.7.8", "0.7.9-rc.1"],
242
+ },
243
+ { branch: "main" },
244
+ );
245
+ expect(d).toMatchObject({ publish: true });
246
+ });
247
+
248
+ test("omitted publishedVersions still refuses a stable when latest already exists", () => {
249
+ const d = decidePublish(
250
+ "0.7.9",
251
+ {
252
+ versionExists: false,
253
+ currentDistTagVersion: "0.7.8",
254
+ },
255
+ { branch: "main" },
256
+ );
257
+ expect(d).toMatchObject({ refuse: true });
258
+ });
259
+
260
+ test("an rc publishes even with no prior rc of the same core", () => {
261
+ const d = decidePublish(
262
+ "0.7.9-rc.1",
263
+ {
264
+ versionExists: false,
265
+ currentDistTagVersion: "0.7.8-rc.5",
266
+ publishedVersions: ["0.7.8", "0.7.8-rc.5"],
267
+ },
268
+ { branch: "next" },
269
+ );
270
+ expect(d).toMatchObject({ publish: true });
271
+ });
272
+
203
273
  test("a tag push of a stable does NOT override the main-only gate", () => {
204
274
  const d = decidePublish(
205
275
  "0.7.9",
@@ -281,6 +351,25 @@ describe("readRegistry", () => {
281
351
  });
282
352
  });
283
353
 
354
+ describe("matchingRcVersions", () => {
355
+ test("matches only the same X.Y.Z rc chain", () => {
356
+ expect(
357
+ matchingRcVersions("0.7.9", ["0.7.8", "0.7.8-rc.1", "0.7.9-rc.1", "0.7.9-rc.2"]),
358
+ ).toEqual(["0.7.9-rc.1", "0.7.9-rc.2"]);
359
+ });
360
+
361
+ test("a prerelease still matches siblings of its core", () => {
362
+ expect(matchingRcVersions("0.7.9-rc.3", ["0.7.9-rc.1", "0.7.8-rc.1"])).toEqual(["0.7.9-rc.1"]);
363
+ });
364
+ });
365
+
366
+ describe("coreVersion", () => {
367
+ test("strips the rc suffix and leaves a stable alone", () => {
368
+ expect(coreVersion("0.7.9-rc.1")).toBe("0.7.9");
369
+ expect(coreVersion("0.7.9")).toBe("0.7.9");
370
+ });
371
+ });
372
+
284
373
  describe("unpublishedDrift", () => {
285
374
  test("no commits → not drifted", () => {
286
375
  expect(unpublishedDrift([]).drifted).toBe(false);
package/src/routes.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  getUnresolvedLinksForNote,
33
33
  getUnresolvedLinksForNotes,
34
34
  listZeroVisibleAmbiguousAsUnresolved,
35
+ listResolvedToInvisibleAsUnresolved,
35
36
  narrowByVisibleBrokenness,
36
37
  sqlHasBrokenLinks,
37
38
  getAmbiguousLinksForNote,
@@ -1026,7 +1027,7 @@ export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCO
1026
1027
  expand,
1027
1028
  excludeTags: parseQueryList(url, "exclude_tag"),
1028
1029
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
1029
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
1030
+ hasLinks: linkOps.sqlHasLinks(parseBoolOrUndef(parseQuery(url, "has_links")), tagScope.raw !== null),
1030
1031
  // Presence filter on dangling outbound wikilinks/structured links
1031
1032
  // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
1032
1033
  // vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
@@ -1376,7 +1377,7 @@ async function handleNotesInner(
1376
1377
  // linkCount injected after filterMetadata on purpose — same as
1377
1378
  // links/attachments above; filterMetadata only touches `metadata`.
1378
1379
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1379
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
1380
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
1380
1381
  }
1381
1382
  return json(result);
1382
1383
  }
@@ -1451,7 +1452,26 @@ async function handleNotesInner(
1451
1452
  embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
1452
1453
  );
1453
1454
  }
1454
- const filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1455
+ let filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1456
+ // Apply the same scoped presence filters as the structured list.
1457
+ filtered = narrowByVisibleAmbiguity(
1458
+ db,
1459
+ filtered,
1460
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1461
+ ambiguityVisibilityFor(db, tagScope),
1462
+ );
1463
+ filtered = narrowByVisibleBrokenness(
1464
+ db,
1465
+ filtered,
1466
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1467
+ ambiguityVisibilityFor(db, tagScope),
1468
+ );
1469
+ filtered = linkOps.narrowByVisibleLinks(
1470
+ db,
1471
+ filtered,
1472
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1473
+ ambiguityVisibilityFor(db, tagScope),
1474
+ );
1455
1475
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1456
1476
  const contentRange = parseContentRangeQuery(url, includeContent);
1457
1477
  if (contentRange.error) return contentRange.error;
@@ -1477,7 +1497,7 @@ async function handleNotesInner(
1477
1497
  output = output.map((n: any) => filterMetadata(n, inclMeta));
1478
1498
  }
1479
1499
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1480
- const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
1500
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope));
1481
1501
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1482
1502
  }
1483
1503
  return jsonWithWarnings(output, semanticWarnings);
@@ -1646,7 +1666,26 @@ async function handleNotesInner(
1646
1666
  // Tag-scope: drop any result the token isn't permitted to see. Filter
1647
1667
  // happens after the store query so an empty post-filter list still
1648
1668
  // returns 200 [] (consistent with "no matches"), not 403.
1649
- const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1669
+ let results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1670
+ // Apply the same scoped presence filters as the structured list.
1671
+ results = narrowByVisibleAmbiguity(
1672
+ db,
1673
+ results,
1674
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1675
+ ambiguityVisibilityFor(db, tagScope),
1676
+ );
1677
+ results = narrowByVisibleBrokenness(
1678
+ db,
1679
+ results,
1680
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1681
+ ambiguityVisibilityFor(db, tagScope),
1682
+ );
1683
+ results = linkOps.narrowByVisibleLinks(
1684
+ db,
1685
+ results,
1686
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1687
+ ambiguityVisibilityFor(db, tagScope),
1688
+ );
1650
1689
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1651
1690
  const contentRange = parseContentRangeQuery(url, includeContent);
1652
1691
  if (contentRange.error) return contentRange.error;
@@ -1680,6 +1719,7 @@ async function handleNotesInner(
1680
1719
  db,
1681
1720
  output.map((n: any) => n.id),
1682
1721
  parseLinkCountDirection(url),
1722
+ ambiguityVisibilityFor(db, tagScope),
1683
1723
  );
1684
1724
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1685
1725
  }
@@ -1807,6 +1847,12 @@ async function handleNotesInner(
1807
1847
  parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1808
1848
  ambiguityVisibilityFor(db, tagScope),
1809
1849
  );
1850
+ visible = linkOps.narrowByVisibleLinks(
1851
+ db,
1852
+ visible,
1853
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1854
+ ambiguityVisibilityFor(db, tagScope),
1855
+ );
1810
1856
  // Always run the rollup, even on an empty visible set: ungrouped
1811
1857
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
1812
1858
  rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
@@ -1980,6 +2026,12 @@ async function handleNotesInner(
1980
2026
  parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1981
2027
  ambiguityVisibilityFor(db, tagScope),
1982
2028
  );
2029
+ results = linkOps.narrowByVisibleLinks(
2030
+ db,
2031
+ results,
2032
+ parseBoolOrUndef(parseQuery(url, "has_links")),
2033
+ ambiguityVisibilityFor(db, tagScope),
2034
+ );
1983
2035
 
1984
2036
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1985
2037
  const contentRange = parseContentRangeQuery(url, includeContent);
@@ -2059,6 +2111,7 @@ async function handleNotesInner(
2059
2111
  db,
2060
2112
  output.map((n: any) => n.id),
2061
2113
  parseLinkCountDirection(url),
2114
+ ambiguityVisibilityFor(db, tagScope),
2062
2115
  );
2063
2116
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
2064
2117
  }
@@ -2826,7 +2879,7 @@ async function handleNotesInner(
2826
2879
  result.attachments = await store.getAttachments(note.id);
2827
2880
  }
2828
2881
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
2829
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
2882
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
2830
2883
  }
2831
2884
  return json(result);
2832
2885
  }
@@ -4264,9 +4317,16 @@ export function handleUnresolvedWikilinks(
4264
4317
  // target); the two tables are disjoint by construction, so this only
4265
4318
  // guards against a stale row.
4266
4319
  const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
4267
- const collapsed = listZeroVisibleAmbiguousAsUnresolved(db, ambiguityVisibilityFor(db, tagScope)!, limit)
4268
- .filter((row) => inScope(row)
4269
- && !seen.has(`${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`));
4320
+ const visible = ambiguityVisibilityFor(db, tagScope)!;
4321
+ const collapsed = [
4322
+ ...listZeroVisibleAmbiguousAsUnresolved(db, visible, limit),
4323
+ ...listResolvedToInvisibleAsUnresolved(db, visible, limit),
4324
+ ].filter((row) => {
4325
+ const key = `${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`;
4326
+ if (!inScope(row) || seen.has(key)) return false;
4327
+ seen.add(key);
4328
+ return true;
4329
+ });
4270
4330
 
4271
4331
  const merged = [...filtered, ...collapsed].slice(0, limit);
4272
4332
  return Response.json({ unresolved: merged, count: merged.length });