@openparachute/vault 0.7.9-rc.1 → 0.7.9-rc.3

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.
@@ -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[];
@@ -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.
@@ -120,7 +122,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
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
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)." },
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)." },
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." },
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: {
@@ -171,8 +174,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
171
174
  },
172
175
  created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
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
- 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>`, `operator`, `api`. Exact match; indexed." },
175
- last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
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
+ 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
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." },
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 }`." },
@@ -237,7 +240,8 @@ 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, which is broken in that session's sub-vault (vault#239)." },
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:
@@ -341,7 +345,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
341
345
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
342
346
  - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
343
347
 
344
- Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
348
+ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`. When the write was signed with a Nostr key (the hub's NIP-98 door), the \`*Via\` value is \`nostr:<64-hex-pubkey>\` — \`createdBy\` stays the hub USER, so the pubkey is what distinguishes two agents sharing that user.`,
345
349
  inputSchema: {
346
350
  type: "object",
347
351
  properties: {