@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
@@ -9,6 +9,7 @@ import * as indexedFieldOps from "./indexed-fields.js";
9
9
  import { resolveLinkTarget } from "./wikilinks.js";
10
10
  import { generateUlid, ULID_REGEX } from "./ulid.js";
11
11
  import { getVaultMap, extractH1Title, findNotesByTitle, getNoteByTitle, validatePath, PathValidationError } from "./notes.js";
12
+ import { transactionAsync } from "./txn.js";
12
13
 
13
14
  let store: SqliteStore;
14
15
  let db: Database;
@@ -63,6 +64,51 @@ describe("notes", async () => {
63
64
  expect(updated.updatedAt).toBeTruthy();
64
65
  });
65
66
 
67
+ it("rolls back the note row when wikilink re-indexing fails mid-update", async () => {
68
+ const source = await store.createNote("before", { path: "Source" });
69
+ await store.createNote("target", { path: "Target" });
70
+ db.exec(`
71
+ CREATE TRIGGER fail_link_insert
72
+ BEFORE INSERT ON links
73
+ BEGIN
74
+ SELECT RAISE(ABORT, 'forced mid-update failure');
75
+ END;
76
+ `);
77
+
78
+ await expect(
79
+ store.updateNote(source.id, { content: "after [[Target]]" }),
80
+ ).rejects.toThrow("forced mid-update failure");
81
+
82
+ const unchanged = await store.getNote(source.id);
83
+ expect(unchanged?.content).toBe("before");
84
+ expect(unchanged?.updatedAt).toBe(source.updatedAt);
85
+ expect(await store.getLinks(source.id)).toEqual([]);
86
+ });
87
+
88
+ it("does not dispatch update hooks for an outer batch that rolls back", async () => {
89
+ const first = await store.createNote("first");
90
+ const second = await store.createNote("second");
91
+ const fired: string[] = [];
92
+ store.hooks.onNote({
93
+ event: "updated",
94
+ handler: (note) => { fired.push(note.id); },
95
+ });
96
+
97
+ await expect(transactionAsync(db, async () => {
98
+ await store.updateNote(first.id, { content: "changed first" });
99
+ await store.updateNote(second.id, { content: "changed second" });
100
+ throw new Error("forced outer rollback");
101
+ })).rejects.toThrow("forced outer rollback");
102
+ // Let any incorrectly queued dispatches reach the registry before drain.
103
+ await Promise.resolve();
104
+ await Promise.resolve();
105
+ await store.hooks.drain();
106
+
107
+ expect(fired).toEqual([]);
108
+ expect((await store.getNote(first.id))?.content).toBe("first");
109
+ expect((await store.getNote(second.id))?.content).toBe("second");
110
+ });
111
+
66
112
  it("updates note path", async () => {
67
113
  const note = await store.createNote("Test");
68
114
  const updated = await store.updateNote(note.id, { path: "Notes/Test" });
@@ -1049,6 +1095,33 @@ describe("mergeTags", async () => {
1049
1095
  expect(result.merged).toEqual({ v1: 1 });
1050
1096
  expect((await store.getNote(note.id))!.tags).toEqual(["voice"]);
1051
1097
  });
1098
+
1099
+ it("bumps updated_at on notes whose tags actually changed (vault#567)", async () => {
1100
+ const affected = await store.createNote("A", { tags: ["v1"] });
1101
+ const both = await store.createNote("B", { tags: ["v1", "voice"] });
1102
+ const untouched = await store.createNote("C", { tags: ["voice"] });
1103
+ const unrelated = await store.createNote("D", { tags: ["other"] });
1104
+ const affectedAt = affected.updatedAt;
1105
+ const bothAt = both.updatedAt;
1106
+ const untouchedAt = untouched.updatedAt;
1107
+ const unrelatedAt = unrelated.updatedAt;
1108
+
1109
+ // Wall-clock ISO timestamps are millisecond-resolution; wait so the
1110
+ // bump cannot collide with the create timestamp.
1111
+ await Bun.sleep(5);
1112
+ await store.mergeTags(["v1"], "voice");
1113
+
1114
+ const affectedAfter = (await store.getNote(affected.id))!;
1115
+ const bothAfter = (await store.getNote(both.id))!;
1116
+ const untouchedAfter = (await store.getNote(untouched.id))!;
1117
+ const unrelatedAfter = (await store.getNote(unrelated.id))!;
1118
+ expect(affectedAfter.updatedAt > affectedAt).toBe(true);
1119
+ expect(bothAfter.updatedAt > bothAt).toBe(true);
1120
+ expect(untouchedAfter.updatedAt).toBe(untouchedAt);
1121
+ expect(unrelatedAfter.updatedAt).toBe(unrelatedAt);
1122
+ expect(affectedAfter.tags).toEqual(["voice"]);
1123
+ expect(bothAfter.tags).toEqual(["voice"]);
1124
+ });
1052
1125
  });
1053
1126
 
1054
1127
  // ---- Vault Stats ----
@@ -4235,6 +4308,461 @@ describe("MCP tools", async () => {
4235
4308
  expect(byId.get("mq-list-c")).toEqual([]);
4236
4309
  });
4237
4310
 
4311
+ // ---- has_ambiguous_links / include_ambiguous_links (vault#581) ----
4312
+ //
4313
+ // Symmetric with the has_broken_links block above. Before #581 an
4314
+ // ambiguous target was visible ONLY in the transient write-time
4315
+ // `ambiguous_link` warning — nothing was persisted, so `has_broken_links`
4316
+ // did not match the note (it was classified CLEAN by
4317
+ // `has_broken_links: false`) and a later audit could not find it.
4318
+
4319
+ it("query-notes has_ambiguous_links=true surfaces only notes whose wikilink matched two notes", async () => {
4320
+ await store.createNote("first", { id: "mq-amb-c1", path: "one/Dup" });
4321
+ await store.createNote("second", { id: "mq-amb-c2", path: "two/Dup" });
4322
+ await store.createNote("see [[Dup]]", { id: "mq-amb-src", path: "mq-amb-src" });
4323
+ await store.createNote("no links here", { id: "mq-amb-clean", path: "mq-amb-clean" });
4324
+
4325
+ const tools = generateMcpTools(store);
4326
+ const query = tools.find((t) => t.name === "query-notes")!;
4327
+ const result = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
4328
+ expect(result.map((n) => n.path)).toEqual(["mq-amb-src"]);
4329
+ });
4330
+
4331
+ it("query-notes has_ambiguous_links=false excludes notes with an ambiguous link", async () => {
4332
+ await store.createNote("first", { path: "a/Twin" });
4333
+ await store.createNote("second", { path: "b/Twin" });
4334
+ await store.createNote("see [[Twin]]", { id: "mq-amb-src2", path: "mq-amb-src2" });
4335
+ await store.createNote("clean", { id: "mq-amb-clean2", path: "mq-amb-clean2" });
4336
+
4337
+ const tools = generateMcpTools(store);
4338
+ const query = tools.find((t) => t.name === "query-notes")!;
4339
+ const result = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
4340
+ expect(result.map((n) => n.path).sort()).toEqual(["a/Twin", "b/Twin", "mq-amb-clean2"]);
4341
+ });
4342
+
4343
+ it("an ambiguous link is NOT counted as a broken link (the two filters are disjoint)", async () => {
4344
+ await store.createNote("first", { path: "x/Both" });
4345
+ await store.createNote("second", { path: "y/Both" });
4346
+ await store.createNote("see [[Both]]", { id: "mq-amb-disjoint", path: "mq-amb-disjoint" });
4347
+
4348
+ const tools = generateMcpTools(store);
4349
+ const query = tools.find((t) => t.name === "query-notes")!;
4350
+ expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual([]);
4351
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-disjoint"]);
4352
+ });
4353
+
4354
+ it("query-notes has_ambiguous_links is safe on a vault where no link has ever been ambiguous", async () => {
4355
+ // Fresh store, beforeEach — the ambiguous_wikilinks table has never been
4356
+ // created. true should match nothing (not throw); false should be a
4357
+ // no-op (matches everything). Mirrors the has_broken_links pin above.
4358
+ await store.createNote("plain note", { id: "mq-amb-none", path: "mq-amb-none" });
4359
+ const tools = generateMcpTools(store);
4360
+ const query = tools.find((t) => t.name === "query-notes")!;
4361
+ const truthy = await query.execute({ has_ambiguous_links: true, include_content: true }) as any[];
4362
+ expect(truthy).toEqual([]);
4363
+ const falsy = await query.execute({ has_ambiguous_links: false, include_content: true }) as any[];
4364
+ expect(falsy.map((n) => n.path)).toEqual(["mq-amb-none"]);
4365
+ });
4366
+
4367
+ it("query-notes include_ambiguous_links surfaces {target, relationship, candidate_count} for a single note", async () => {
4368
+ await store.createNote("first", { path: "p/Ghost" });
4369
+ await store.createNote("second", { path: "q/Ghost" });
4370
+ await store.createNote("see [[Ghost]]", { id: "mq-amb-single", path: "mq-amb-single" });
4371
+
4372
+ const tools = generateMcpTools(store);
4373
+ const query = tools.find((t) => t.name === "query-notes")!;
4374
+ const result = await query.execute({ id: "mq-amb-single", include_ambiguous_links: true }) as any;
4375
+ expect(result.ambiguous_links).toEqual([{ target: "Ghost", relationship: "wikilink", candidate_count: 2 }]);
4376
+ });
4377
+
4378
+ it("query-notes include_ambiguous_links surfaces a structured link's ambiguous_link entry too", async () => {
4379
+ await store.createNote("first", { path: "s1/Fork" });
4380
+ await store.createNote("second", { path: "s2/Fork" });
4381
+ const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
4382
+ content: "body",
4383
+ path: "mq-amb-structured",
4384
+ links: [{ target: "Fork", relationship: "depends-on" }],
4385
+ }) as any;
4386
+ expect(created.warnings?.[0]?.code).toBe("ambiguous_link");
4387
+
4388
+ const tools = generateMcpTools(store);
4389
+ const query = tools.find((t) => t.name === "query-notes")!;
4390
+ const result = await query.execute({ id: created.id, include_ambiguous_links: true }) as any;
4391
+ expect(result.ambiguous_links).toEqual([{ target: "Fork", relationship: "depends-on", candidate_count: 2 }]);
4392
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
4393
+ });
4394
+
4395
+ it("query-notes include_ambiguous_links is [] for a note with no ambiguous links", async () => {
4396
+ await store.createNote("clean", { id: "mq-amb-clean-single", path: "mq-amb-clean-single" });
4397
+ const tools = generateMcpTools(store);
4398
+ const query = tools.find((t) => t.name === "query-notes")!;
4399
+ const result = await query.execute({ id: "mq-amb-clean-single", include_ambiguous_links: true }) as any;
4400
+ expect(result.ambiguous_links).toEqual([]);
4401
+ });
4402
+
4403
+ it("query-notes include_ambiguous_links works in list mode, batched across the page", async () => {
4404
+ await store.createNote("first", { path: "l1/Echo" });
4405
+ await store.createNote("second", { path: "l2/Echo" });
4406
+ await store.createNote("see [[Echo]]", { id: "mq-amb-list-a", path: "mq-amb-list-a" });
4407
+ await store.createNote("also [[Echo]]", { id: "mq-amb-list-b", path: "mq-amb-list-b" });
4408
+ await store.createNote("clean", { id: "mq-amb-list-c", path: "mq-amb-list-c" });
4409
+
4410
+ const tools = generateMcpTools(store);
4411
+ const query = tools.find((t) => t.name === "query-notes")!;
4412
+ const result = await query.execute({ include_ambiguous_links: true, include_content: true }) as any[];
4413
+ const byId = new Map(result.map((n: any) => [n.id, n.ambiguous_links]));
4414
+ expect(byId.get("mq-amb-list-a")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
4415
+ expect(byId.get("mq-amb-list-b")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
4416
+ expect(byId.get("mq-amb-list-c")).toEqual([]);
4417
+ });
4418
+
4419
+ // ---- self-healing: ambiguity must resolve when it stops being ambiguous ----
4420
+
4421
+ it("deleting one colliding candidate resolves the ambiguity — link created, note drops out of has_ambiguous_links", async () => {
4422
+ const keep = await store.createNote("first", { path: "keep/Heal" });
4423
+ const drop = await store.createNote("second", { path: "drop/Heal" });
4424
+ const src = await store.createNote("see [[Heal]]", { id: "mq-amb-heal", path: "mq-amb-heal" });
4425
+
4426
+ const tools = generateMcpTools(store);
4427
+ const query = tools.find((t) => t.name === "query-notes")!;
4428
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
4429
+
4430
+ await store.deleteNote(drop.id);
4431
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4432
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4433
+ expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
4434
+ });
4435
+
4436
+ it("renaming one colliding candidate out of the way resolves a structured link's ambiguity", async () => {
4437
+ // Structured links (and typed `reference` fields) are the clean rename
4438
+ // case: unlike a content [[wikilink]], nothing rewrites the stored
4439
+ // target string, so the sweep is the only thing that can heal it.
4440
+ const keep = await store.createNote("first", { path: "keep/Fork" });
4441
+ const moved = await store.createNote("second", { path: "move/Fork" });
4442
+ const created = await generateMcpTools(store).find((t) => t.name === "create-note")!.execute({
4443
+ content: "body", path: "mq-amb-rename", links: [{ target: "Fork", relationship: "depends-on" }],
4444
+ }) as any;
4445
+
4446
+ const tools = generateMcpTools(store);
4447
+ const query = tools.find((t) => t.name === "query-notes")!;
4448
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).map((n: any) => n.id)).toEqual([created.id]);
4449
+
4450
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4451
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4452
+ const links = await query.execute({ id: created.id, include_links: true }) as any;
4453
+ expect(links.links.map((l: any) => [l.targetId, l.relationship])).toEqual([[keep.id, "depends-on"]]);
4454
+ });
4455
+
4456
+ it("renaming a colliding candidate also clears a content wikilink's ambiguity (via cascadeRename)", async () => {
4457
+ // vault#708 — an AMBIGUOUS `[[Rename]]` never pointed at the renamed
4458
+ // note (it pointed at nothing: two candidates, no `links` row), so the
4459
+ // cascade must leave the text alone and let the vault#581 sweep heal it.
4460
+ // With `move/Rename` moved away, `keep/Rename` is the only candidate
4461
+ // left, so the bracket resolves to KEEP. Before vault#708 the cascade
4462
+ // rewrote the text to `[[Elsewhere]]` on a basename match and the link
4463
+ // followed the note that moved away — the bug this pin used to record.
4464
+ const keep = await store.createNote("first", { path: "keep/Rename" });
4465
+ const moved = await store.createNote("second", { path: "move/Rename" });
4466
+ const src = await store.createNote("see [[Rename]]", { id: "mq-amb-rename2", path: "mq-amb-rename2" });
4467
+
4468
+ const tools = generateMcpTools(store);
4469
+ const query = tools.find((t) => t.name === "query-notes")!;
4470
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
4471
+
4472
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4473
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4474
+ expect((await store.getNote(src.id))!.content).toBe("see [[Rename]]");
4475
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4476
+ expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
4477
+ });
4478
+
4479
+ // ---- vault#239: the visibility model reaches the BROKEN-links surface ----
4480
+ //
4481
+ // vault#581's auth review made the AMBIGUOUS surface answer on a scoped
4482
+ // reader's own sub-vault (>=2 VISIBLE candidates). The same question has a
4483
+ // second answer core must give: 0 visible candidates means the reference is
4484
+ // BROKEN for that reader — whichever table its row currently lives in.
4485
+ //
4486
+ // Without this, `refreshAmbiguousLinks`' delete-time demotion into
4487
+ // `unresolved_wikilinks` is the ONLY thing that makes such a note "broken",
4488
+ // so the answer flips purely because notes the reader can't see were
4489
+ // deleted. Core stays scope-unaware: it just invokes the injected
4490
+ // `ambiguityVisible` closure, exactly as the ambiguity surface does.
4491
+
4492
+ /** `[[Dup]]` with two candidates, neither of which `visibleIds` contains. */
4493
+ async function hiddenCandidates(prefix: string) {
4494
+ const h1 = await store.createNote("hidden one", { path: `${prefix}-p1/Dup` });
4495
+ const h2 = await store.createNote("hidden two", { path: `${prefix}-p2/Dup` });
4496
+ const src = await store.createNote("see [[Dup]]", { id: `${prefix}-src`, path: `${prefix}-src` });
4497
+ return { h1, h2, src, hidden: new Set([h1.id, h2.id]) };
4498
+ }
4499
+
4500
+ it("include_broken_links reports a target whose only candidates fail the visibility predicate", async () => {
4501
+ const { src, hidden } = await hiddenCandidates("mq-vis-brk");
4502
+ const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
4503
+ const query = tools.find((t) => t.name === "query-notes")!;
4504
+
4505
+ const result = await query.execute({ id: src.id, include_broken_links: true }) as any;
4506
+ expect(result.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
4507
+ // ...and it is NOT also reported as ambiguous (the surfaces stay disjoint).
4508
+ expect((await query.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
4509
+ .toEqual([]);
4510
+ });
4511
+
4512
+ it("has_broken_links answers on the visible sub-vault and does not move when a hidden candidate is deleted", async () => {
4513
+ const { src, h1, h2, hidden } = await hiddenCandidates("mq-vis-heal");
4514
+ const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => !hidden.has(id) });
4515
+ const query = tools.find((t) => t.name === "query-notes")!;
4516
+ const snapshot = async () => ({
4517
+ truthy: ((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id),
4518
+ falsy: ((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id),
4519
+ broken: (await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links,
4520
+ });
4521
+
4522
+ const before = await snapshot();
4523
+ expect(before.truthy).toContain(src.id);
4524
+ expect(before.falsy).not.toContain(src.id);
4525
+
4526
+ await store.deleteNote(h1.id);
4527
+ await store.deleteNote(h2.id);
4528
+
4529
+ const after = await snapshot();
4530
+ expect(after.truthy).toContain(src.id);
4531
+ expect(after.falsy).not.toContain(src.id);
4532
+ expect(after.broken).toEqual(before.broken);
4533
+ });
4534
+
4535
+ it("a target with ONE visible candidate is neither broken nor ambiguous (negative control)", async () => {
4536
+ // Guards against a fix that just reports every ambiguous row as broken:
4537
+ // one candidate survives the predicate, so the reference RESOLVES in the
4538
+ // reader's sub-vault.
4539
+ const keep = await store.createNote("visible", { path: "mq-vis-one-a/Dup" });
4540
+ const hide = await store.createNote("hidden", { path: "mq-vis-one-b/Dup" });
4541
+ const src = await store.createNote("see [[Dup]]", { id: "mq-vis-one-src", path: "mq-vis-one-src" });
4542
+
4543
+ const tools = generateMcpTools(store, { ambiguityVisible: (id: string) => id !== hide.id });
4544
+ const query = tools.find((t) => t.name === "query-notes")!;
4545
+ const result = await query.execute({
4546
+ id: src.id, include_broken_links: true, include_ambiguous_links: true,
4547
+ }) as any;
4548
+ expect(result.broken_links).toEqual([]);
4549
+ expect(result.ambiguous_links).toEqual([]);
4550
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
4551
+ .not.toContain(src.id);
4552
+ expect(keep.id).toBeTruthy();
4553
+ });
4554
+
4555
+ it("a STALE ambiguous row that resolves cleanly again is not reported broken", async () => {
4556
+ // A `WikilinkResolution` that RESOLVED carries an empty `candidates`
4557
+ // array, which a naive "count the visible candidates" check would read
4558
+ // as 0 == broken. The row goes stale through the documented vault#581
4559
+ // gap: ambiguity via the H1-TITLE fallback, cleared by editing one
4560
+ // title, which does not re-run the sweep (only a path change does).
4561
+ await store.createNote("# Twin\n\nfirst", { id: "mq-vis-stale-a", path: "mq-vis-stale-a" });
4562
+ const b = await store.createNote("# Twin\n\nsecond", { id: "mq-vis-stale-b", path: "mq-vis-stale-b" });
4563
+ const src = await store.createNote("see [[Twin]]", { id: "mq-vis-stale-src", path: "mq-vis-stale-src" });
4564
+
4565
+ const unscoped = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4566
+ expect((await unscoped.execute({ id: src.id, include_ambiguous_links: true }) as any).ambiguous_links)
4567
+ .toEqual([{ target: "Twin", relationship: "wikilink", candidate_count: 2 }]);
4568
+
4569
+ await store.updateNote(b.id, { content: "# Other\n\nsecond" });
4570
+
4571
+ // Row still says "ambiguous"; the target now resolves to the ONE
4572
+ // remaining, visible note — so neither surface may report it.
4573
+ const query = generateMcpTools(store, { ambiguityVisible: () => true })
4574
+ .find((t) => t.name === "query-notes")!;
4575
+ const result = await query.execute({
4576
+ id: src.id, include_broken_links: true, include_ambiguous_links: true,
4577
+ }) as any;
4578
+ expect(result.broken_links).toEqual([]);
4579
+ expect(result.ambiguous_links).toEqual([]);
4580
+ });
4581
+
4582
+ it("NO predicate injected → broken-links answers from the persisted tables only (regression)", async () => {
4583
+ const { src, h1, h2 } = await hiddenCandidates("mq-vis-unscoped");
4584
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4585
+ const read = async () => await query.execute({
4586
+ id: src.id, include_broken_links: true, include_ambiguous_links: true,
4587
+ }) as any;
4588
+
4589
+ const before = await read();
4590
+ expect(before.broken_links).toEqual([]);
4591
+ expect(before.ambiguous_links)
4592
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
4593
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
4594
+ .not.toContain(src.id);
4595
+
4596
+ await store.deleteNote(h1.id);
4597
+ await store.deleteNote(h2.id);
4598
+
4599
+ const after = await read();
4600
+ expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
4601
+ expect(after.ambiguous_links).toEqual([]);
4602
+ });
4603
+
4604
+ // ---- vault#708: the rename cascade follows resolution, not basenames ----
4605
+
4606
+ it("cascadeRename leaves an unambiguous full-path bracket aimed at the OTHER same-named note alone", async () => {
4607
+ const keep = await store.createNote("first", { path: "keep/Rename" });
4608
+ const moved = await store.createNote("second", { path: "move/Rename" });
4609
+ const src = await store.createNote("see [[keep/Rename]]", { id: "c708-other", path: "c708-other" });
4610
+
4611
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4612
+
4613
+ expect((await store.getNote(src.id))!.content).toBe("see [[keep/Rename]]");
4614
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4615
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4616
+ expect(links.links.map((l: any) => l.targetId)).toEqual([keep.id]);
4617
+ });
4618
+
4619
+ it("cascadeRename follows an unambiguous BASENAME bracket and keeps it a basename", async () => {
4620
+ const moved = await store.createNote("only one", { path: "move/Rename" });
4621
+ const src = await store.createNote("see [[Rename]]", { id: "c708-basename", path: "c708-basename" });
4622
+
4623
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4624
+
4625
+ expect((await store.getNote(src.id))!.content).toBe("see [[Elsewhere]]");
4626
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4627
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4628
+ expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
4629
+ });
4630
+
4631
+ it("cascadeRename widens a basename bracket to the full path when the NEW basename is ambiguous", async () => {
4632
+ const moved = await store.createNote("first", { path: "a/Foo" });
4633
+ await store.createNote("collides with the new name", { path: "b/Bar" });
4634
+ const src = await store.createNote("see [[Foo]]", { id: "c708-widen", path: "c708-widen" });
4635
+
4636
+ await store.updateNote(moved.id, { path: "a/Bar" });
4637
+
4638
+ // `[[Bar]]` would now match two notes, so the bracket widens rather than
4639
+ // becoming ambiguous.
4640
+ expect((await store.getNote(src.id))!.content).toBe("see [[a/Bar]]");
4641
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4642
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4643
+ expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
4644
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4645
+ });
4646
+
4647
+ it("cascadeRename rewrites alias / anchor / block-ref / embed / full-path / .ext forms, and skips code fences", async () => {
4648
+ const moved = await store.createNote("only one", { path: "move/Rename" });
4649
+ const src = await store.createNote(
4650
+ "a [[Rename|shown]] b [[Rename#Heading]] c [[Rename#^blk]] d [[move/Rename]] e [[move/Rename.md]] f ![[Rename]]\n\n```\ng [[Rename]]\n```\n",
4651
+ { id: "c708-forms", path: "c708-forms" },
4652
+ );
4653
+
4654
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4655
+
4656
+ expect((await store.getNote(src.id))!.content).toBe(
4657
+ "a [[Elsewhere|shown]] b [[Elsewhere#Heading]] c [[Elsewhere#^blk]] d [[move/Elsewhere]] e [[move/Elsewhere.md]] f ![[Elsewhere]]\n\n```\ng [[Rename]]\n```\n",
4658
+ );
4659
+ });
4660
+
4661
+ it("cascadeRename rewrites an explicit-extension bracket on a non-md note", async () => {
4662
+ const moved = await store.createNote("csv", { path: "move/Data", extension: "csv" });
4663
+ const src = await store.createNote("see [[move/Data.csv]]", { id: "c708-ext", path: "c708-ext" });
4664
+
4665
+ await store.updateNote(moved.id, { path: "move/Info" });
4666
+
4667
+ expect((await store.getNote(src.id))!.content).toBe("see [[move/Info.csv]]");
4668
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4669
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4670
+ expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
4671
+ });
4672
+
4673
+ it("cascadeRename rewrites the renamed note's OWN self-referencing bracket", async () => {
4674
+ // Self-links are deliberately never given a `links` row, so the note
4675
+ // itself has to be seeded into the cascade's source set.
4676
+ const moved = await store.createNote("about [[move/Rename]] itself", { path: "move/Rename" });
4677
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4678
+ expect((await store.getNote(moved.id))!.content).toBe("about [[move/Elsewhere]] itself");
4679
+ });
4680
+
4681
+ it("cascadeRename leaves a bracket that resolved through the H1-title fallback alone", async () => {
4682
+ // A title-fallback link doesn't depend on the path, so a repath must not
4683
+ // touch it — and it keeps resolving afterwards.
4684
+ const moved = await store.createNote("# Distinct Title\n\nbody", { path: "move/Rename" });
4685
+ const src = await store.createNote("see [[Distinct Title]]", { id: "c708-title", path: "c708-title" });
4686
+
4687
+ await store.updateNote(moved.id, { path: "move/Elsewhere" });
4688
+
4689
+ expect((await store.getNote(src.id))!.content).toBe("see [[Distinct Title]]");
4690
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
4691
+ const links = await query.execute({ id: src.id, include_links: true }) as any;
4692
+ expect(links.links.map((l: any) => l.targetId)).toEqual([moved.id]);
4693
+ });
4694
+
4695
+ it("deleting BOTH colliding candidates turns the ambiguous link into a broken one", async () => {
4696
+ const a = await store.createNote("first", { path: "d1/Gone" });
4697
+ const b = await store.createNote("second", { path: "d2/Gone" });
4698
+ await store.createNote("see [[Gone]]", { id: "mq-amb-to-broken", path: "mq-amb-to-broken" });
4699
+
4700
+ const tools = generateMcpTools(store);
4701
+ const query = tools.find((t) => t.name === "query-notes")!;
4702
+ await store.deleteNote(a.id);
4703
+ await store.deleteNote(b.id);
4704
+
4705
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4706
+ expect((await query.execute({ has_broken_links: true }) as any[]).map((n: any) => n.id)).toEqual(["mq-amb-to-broken"]);
4707
+ const single = await query.execute({ id: "mq-amb-to-broken", include_broken_links: true }) as any;
4708
+ expect(single.broken_links).toEqual([{ target: "Gone", relationship: "wikilink" }]);
4709
+ });
4710
+
4711
+ it("editing the wikilink out of the content clears the persisted ambiguity", async () => {
4712
+ await store.createNote("first", { path: "e1/Vanish" });
4713
+ await store.createNote("second", { path: "e2/Vanish" });
4714
+ const src = await store.createNote("see [[Vanish]]", { id: "mq-amb-edit", path: "mq-amb-edit" });
4715
+
4716
+ const tools = generateMcpTools(store);
4717
+ const query = tools.find((t) => t.name === "query-notes")!;
4718
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
4719
+
4720
+ await store.updateNote(src.id, { content: "no links now" });
4721
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4722
+ });
4723
+
4724
+ it("deleting the SOURCE note cascades its ambiguous rows away", async () => {
4725
+ await store.createNote("first", { path: "c1/Casc" });
4726
+ await store.createNote("second", { path: "c2/Casc" });
4727
+ const src = await store.createNote("see [[Casc]]", { id: "mq-amb-cascade", path: "mq-amb-cascade" });
4728
+
4729
+ const tools = generateMcpTools(store);
4730
+ const query = tools.find((t) => t.name === "query-notes")!;
4731
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(1);
4732
+ await store.deleteNote(src.id);
4733
+ expect((await query.execute({ has_ambiguous_links: true }) as any[]).length).toBe(0);
4734
+ });
4735
+
4736
+ it("a THIRD colliding note bumps the persisted candidate_count", async () => {
4737
+ await store.createNote("first", { path: "t1/Trio" });
4738
+ await store.createNote("second", { path: "t2/Trio" });
4739
+ await store.createNote("see [[Trio]]", { id: "mq-amb-count", path: "mq-amb-count" });
4740
+
4741
+ const tools = generateMcpTools(store);
4742
+ const query = tools.find((t) => t.name === "query-notes")!;
4743
+ let single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
4744
+ expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 2 }]);
4745
+
4746
+ await store.createNote("third", { path: "t3/Trio" });
4747
+ single = await query.execute({ id: "mq-amb-count", include_ambiguous_links: true }) as any;
4748
+ expect(single.ambiguous_links).toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 3 }]);
4749
+ });
4750
+
4751
+ it("has_ambiguous_links is bound into the cursor query-hash", async () => {
4752
+ await store.createNote("first", { path: "cur1/Hash" });
4753
+ await store.createNote("second", { path: "cur2/Hash" });
4754
+ await store.createNote("see [[Hash]]", { id: "mq-amb-cursor", path: "mq-amb-cursor" });
4755
+
4756
+ const tools = generateMcpTools(store);
4757
+ const query = tools.find((t) => t.name === "query-notes")!;
4758
+ const page = await query.execute({ cursor: "", limit: 1, has_ambiguous_links: true }) as any;
4759
+ expect(page.notes.map((n: any) => n.id)).toEqual(["mq-amb-cursor"]);
4760
+ // Flipping the filter must invalidate the cursor rather than silently
4761
+ // continuing the prior filter's watermark.
4762
+ expect(query.execute({ cursor: page.next_cursor, limit: 1, has_ambiguous_links: false }))
4763
+ .rejects.toThrow(/cursor was minted for a different query/);
4764
+ });
4765
+
4238
4766
  it("query-notes metadata operator query routes through the indexed column", async () => {
4239
4767
  const { declareField } = await import("./indexed-fields.js");
4240
4768
  declareField(db, "priority", "INTEGER", "project");
@@ -8817,6 +9345,24 @@ describe("vault projection (vault#271)", async () => {
8817
9345
  expect(md).toContain("Querying");
8818
9346
  });
8819
9347
 
9348
+ it("markdown brief skips a non-string description instead of throwing (vault#669 recovery)", async () => {
9349
+ const { buildVaultProjection, projectionToMarkdown } = await import(
9350
+ "./vault-projection.ts"
9351
+ );
9352
+
9353
+ const projection = buildVaultProjection(db, { includeStats: true });
9354
+ const md = projectionToMarkdown({
9355
+ vaultName: "poisoned",
9356
+ // A write door that skipped the type guard used to persist a number
9357
+ // and take down initialize via `description.trim is not a function`.
9358
+ description: 123 as unknown as string,
9359
+ projection,
9360
+ });
9361
+
9362
+ expect(md).toContain('Parachute Vault "poisoned"');
9363
+ expect(md).not.toContain("123");
9364
+ });
9365
+
8820
9366
  it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
8821
9367
  const { buildVaultProjection, projectionToMarkdown } = await import(
8822
9368
  "./vault-projection.ts"
@@ -174,6 +174,7 @@ export interface QueryHashInputs {
174
174
  hasTags?: boolean;
175
175
  hasLinks?: boolean;
176
176
  hasBrokenLinks?: boolean;
177
+ hasAmbiguousLinks?: boolean;
177
178
  path?: string;
178
179
  pathPrefix?: string;
179
180
  excludePathPrefix?: string[];
@@ -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
+ });