@openparachute/vault 0.4.0 → 0.4.4-rc.11

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.
@@ -357,7 +357,7 @@ describe("renameTag", async () => {
357
357
  const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
358
358
 
359
359
  const result = await store.renameTag("voice", "memo");
360
- expect(result).toEqual({ renamed: 2 });
360
+ expect(result).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
361
361
 
362
362
  expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
363
363
  expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
@@ -386,7 +386,7 @@ describe("renameTag", async () => {
386
386
  await store.untagNote((await store.queryNotes({}))[0].id, ["doomed"]);
387
387
 
388
388
  const result = await store.renameTag("doomed", "archived");
389
- expect(result).toEqual({ renamed: 0 });
389
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
390
390
  const tags = await store.listTags();
391
391
  expect(tags.some((t) => t.name === "doomed")).toBe(false);
392
392
  expect(tags.some((t) => t.name === "archived")).toBe(true);
@@ -397,7 +397,7 @@ describe("renameTag", async () => {
397
397
  await store.createNote("B", { tags: ["new"] });
398
398
 
399
399
  const result = await store.renameTag("old", "new");
400
- expect(result).toEqual({ error: "target_exists" });
400
+ expect(result).toMatchObject({ error: "target_exists", conflicting: ["new"] });
401
401
 
402
402
  // No bleed — both tags still present with their original counts.
403
403
  const tags = await store.listTags();
@@ -413,11 +413,325 @@ describe("renameTag", async () => {
413
413
  it("same-name rename is a no-op on an existing tag", async () => {
414
414
  await store.createNote("A", { tags: ["voice"] });
415
415
  const result = await store.renameTag("voice", "voice");
416
- expect(result).toEqual({ renamed: 0 });
416
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
417
417
  expect((await store.listTags()).find((t) => t.name === "voice")!.count).toBe(1);
418
418
  });
419
419
  });
420
420
 
421
+ // ---- Tag rename cascade (vault#240 + #247) ----
422
+
423
+ describe("renameTag cascade (vault#240 + #247)", async () => {
424
+ it("1. rewrites note bodies with #tag references", async () => {
425
+ const note = await store.createNote(
426
+ "Today's #task is important. Also see #task/work and the #other tag.",
427
+ { tags: ["task"] },
428
+ );
429
+
430
+ const result = await store.renameTag("task", "todo");
431
+ expect(result).toMatchObject({ renamed: 1, notes_rewritten: 1 });
432
+
433
+ const fresh = await store.getNote(note.id);
434
+ expect(fresh!.content).toContain("#todo");
435
+ expect(fresh!.content).not.toContain("#task ");
436
+ expect(fresh!.content).toContain("#other"); // untouched
437
+ });
438
+
439
+ it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
440
+ await store.createNote("a", { tags: ["task"] });
441
+ await store.createNote("b", { tags: ["task/work"] });
442
+ await store.createNote("c", { tags: ["task/work/client"] });
443
+
444
+ const result = await store.renameTag("task", "todo");
445
+ expect(result).toMatchObject({ renamed: 3, sub_tags_renamed: 2 });
446
+
447
+ const tags = (await store.listTags()).map((t) => t.name).sort();
448
+ expect(tags).toContain("todo");
449
+ expect(tags).toContain("todo/work");
450
+ expect(tags).toContain("todo/work/client");
451
+ expect(tags.some((t) => t.startsWith("task"))).toBe(false);
452
+ });
453
+
454
+ it("3. rewrites parent_names refs in OTHER tag rows (closes #247)", async () => {
455
+ await store.upsertTagRecord("task", { description: "tasks" });
456
+ await store.upsertTagRecord("voice", {
457
+ parent_names: ["manual", "task"],
458
+ });
459
+
460
+ const result = await store.renameTag("task", "todo");
461
+ expect(result).toMatchObject({ renamed: 0, parent_refs_updated: 1 });
462
+
463
+ const voice = await store.getTagRecord("voice");
464
+ expect(voice?.parent_names).toEqual(["manual", "todo"]);
465
+ });
466
+
467
+ it("4. rewrites tokens.scoped_tags JSON arrays", async () => {
468
+ await store.upsertTagRecord("task", {});
469
+ await store.upsertTagRecord("project", {});
470
+
471
+ const now = new Date().toISOString();
472
+ db.prepare(
473
+ `INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
474
+ VALUES (?, ?, ?, ?, ?, ?)`,
475
+ ).run("h_one", "tok-1", "full", "vault:read", JSON.stringify(["task"]), now);
476
+ db.prepare(
477
+ `INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
478
+ VALUES (?, ?, ?, ?, ?, ?)`,
479
+ ).run("h_two", "tok-2", "full", "vault:read", JSON.stringify(["project"]), now);
480
+
481
+ const result = await store.renameTag("task", "todo");
482
+ expect(result).toMatchObject({ tokens_updated: 1 });
483
+
484
+ const refreshed = db
485
+ .prepare("SELECT token_hash, scoped_tags FROM tokens ORDER BY token_hash")
486
+ .all() as { token_hash: string; scoped_tags: string }[];
487
+ expect(JSON.parse(refreshed[0]!.scoped_tags)).toEqual(["todo"]);
488
+ // Untouched.
489
+ expect(JSON.parse(refreshed[1]!.scoped_tags)).toEqual(["project"]);
490
+ });
491
+
492
+ it("5. rewrites #tag and [[_tags/...]] references in note bodies (incl. sub-tags)", async () => {
493
+ await store.upsertTagRecord("task/work", {});
494
+ await store.upsertTagRecord("task", {});
495
+ const a = await store.createNote(
496
+ "#task is important #task/work and a wikilink: [[_tags/task]]",
497
+ { tags: ["task"] },
498
+ );
499
+
500
+ const result = await store.renameTag("task", "todo");
501
+ expect(result.renamed).toBeGreaterThan(0);
502
+ if ("notes_rewritten" in result) expect(result.notes_rewritten).toBe(1);
503
+
504
+ const fresh = await store.getNote(a.id);
505
+ expect(fresh!.content).toContain("#todo is important");
506
+ expect(fresh!.content).toContain("#todo/work");
507
+ expect(fresh!.content).toContain("[[_tags/todo]]");
508
+ expect(fresh!.content).not.toContain("#task ");
509
+ expect(fresh!.content).not.toContain("[[_tags/task]");
510
+ });
511
+
512
+ it("6. pre-flight collision aborts without mutation when target exists", async () => {
513
+ await store.createNote("t", { tags: ["task"] });
514
+ await store.createNote("p", { tags: ["project"] });
515
+
516
+ const result = await store.renameTag("task", "project");
517
+ expect(result).toMatchObject({ error: "target_exists", conflicting: ["project"] });
518
+
519
+ // Both tags still present, untouched counts.
520
+ const tags = await store.listTags();
521
+ expect(tags.find((t) => t.name === "task")?.count).toBe(1);
522
+ expect(tags.find((t) => t.name === "project")?.count).toBe(1);
523
+ });
524
+
525
+ it("7. transactional rollback leaves the original state intact on mid-cascade failure", async () => {
526
+ await store.createNote("a", { tags: ["task"] });
527
+ await store.createNote("b", { tags: ["task/work"] });
528
+
529
+ // Inject failure: drop the tags table mid-transaction by intercepting
530
+ // the JSON cascade pass. Easiest reliable hook: corrupt the
531
+ // tokens.scoped_tags column with a value that will fail the JSON
532
+ // cascade's UPDATE (use a token_hash that violates a constraint when
533
+ // the cascade rewrites it). Simpler: spy on noteOps via monkey-patch.
534
+ //
535
+ // We use the simplest reliable approach: a row lock conflict. Two
536
+ // statements writing the same row in a deferred transaction would
537
+ // require two connections; instead we drop a required table at the
538
+ // SQL layer to force a SQL error on a downstream UPDATE inside the
539
+ // cascade. Restore after the test.
540
+ const originalDeleteTag = (db as any).prepare;
541
+ let dropOnce = false;
542
+ (db as any).prepare = function (sql: string) {
543
+ // Force the tag-row pass to fail on the DELETE step by dropping
544
+ // the tags table out from under it after the first INSERT.
545
+ if (!dropOnce && sql.startsWith("DELETE FROM tags WHERE name = ?")) {
546
+ dropOnce = true;
547
+ const stmt = originalDeleteTag.call(this, sql);
548
+ const wrapped = {
549
+ run: (...args: any[]) => {
550
+ (db as any).prepare = originalDeleteTag;
551
+ throw new Error("synthetic mid-cascade failure");
552
+ },
553
+ };
554
+ return wrapped;
555
+ }
556
+ return originalDeleteTag.call(this, sql);
557
+ };
558
+
559
+ let threw = false;
560
+ try {
561
+ await store.renameTag("task", "todo");
562
+ } catch {
563
+ threw = true;
564
+ }
565
+ (db as any).prepare = originalDeleteTag;
566
+ expect(threw).toBe(true);
567
+
568
+ // Original state intact: task tags still present, todo absent.
569
+ const tags = (await store.listTags()).map((t) => t.name);
570
+ expect(tags).toContain("task");
571
+ expect(tags).toContain("task/work");
572
+ expect(tags).not.toContain("todo");
573
+ expect(tags).not.toContain("todo/work");
574
+ });
575
+
576
+ it("8. invalidates hierarchy + schema caches after rename", async () => {
577
+ await store.upsertTagRecord("task", {
578
+ fields: { status: { type: "string" } },
579
+ });
580
+ await store.upsertTagRecord("task/work", { parent_names: ["task"] });
581
+ await store.createNote("a", { tags: ["task/work"] });
582
+
583
+ // Prime the caches by querying via the hierarchy-aware path.
584
+ await store.queryNotes({ tags: ["task"] });
585
+
586
+ await store.renameTag("task", "todo");
587
+
588
+ // queryNotes via the new tag must find the note that's now tagged
589
+ // todo/work (descendant of todo via parent_names rewrite).
590
+ const found = await store.queryNotes({ tags: ["todo"] });
591
+ expect(found.length).toBe(1);
592
+
593
+ // validateNoteAgainstSchemas must surface fields under the new tag —
594
+ // proves the schema-config cache was busted (otherwise the resolver
595
+ // would still be keyed on `task`).
596
+ const status = store.validateNoteAgainstSchemas({
597
+ tags: ["todo"],
598
+ metadata: { status: 123 },
599
+ });
600
+ expect(status?.warnings.some((w) => w.reason === "type_mismatch")).toBe(true);
601
+ });
602
+
603
+ it("9. self-rename is a structured no-op when the source exists", async () => {
604
+ await store.createNote("a", { tags: ["task"] });
605
+ const result = await store.renameTag("task", "task");
606
+ expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
607
+ expect((await store.listTags()).find((t) => t.name === "task")?.count).toBe(1);
608
+ });
609
+
610
+ it("10. preserves transitive inheritance through the cascade (manual extends note; voice extends manual; renaming manual → instruction keeps voice's effective fields)", async () => {
611
+ await store.upsertTagRecord("note", {
612
+ fields: { topic: { type: "string" } },
613
+ });
614
+ await store.upsertTagRecord("manual", {
615
+ fields: { author: { type: "string" } },
616
+ parent_names: ["note"],
617
+ });
618
+ await store.upsertTagRecord("voice", {
619
+ parent_names: ["manual"],
620
+ });
621
+
622
+ const result = await store.renameTag("manual", "instruction");
623
+ expect(result.renamed).toBe(0); // no notes
624
+ if ("parent_refs_updated" in result) expect(result.parent_refs_updated).toBe(1);
625
+
626
+ // Voice's parent_names now references `instruction` (the renamed parent).
627
+ const voice = await store.getTagRecord("voice");
628
+ expect(voice?.parent_names).toEqual(["instruction"]);
629
+
630
+ // Voice's effective fields still inherit through instruction → note.
631
+ const status = store.validateNoteAgainstSchemas({
632
+ tags: ["voice"],
633
+ metadata: { topic: 123, author: "ok" },
634
+ });
635
+ expect(status?.warnings.some((w) => w.field === "topic" && w.reason === "type_mismatch")).toBe(
636
+ true,
637
+ );
638
+ });
639
+
640
+ it("11. rewrites indexed_fields.declarer_tags JSON arrays (#275 fold N3)", async () => {
641
+ // Drive through the update-tag MCP tool — it owns indexed-field
642
+ // lifecycle (see mcp.ts §update-tag); store.upsertTagRecord does
643
+ // not populate the `indexed_fields` table.
644
+ const tools = generateMcpTools(store);
645
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
646
+ await updateTag.execute({
647
+ tag: "task",
648
+ fields: { status: { type: "string", indexed: true } },
649
+ });
650
+ await updateTag.execute({
651
+ tag: "project",
652
+ fields: { status: { type: "string", indexed: true } },
653
+ });
654
+
655
+ const beforeRow = db
656
+ .prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
657
+ .get("status") as { declarer_tags: string };
658
+ expect(JSON.parse(beforeRow.declarer_tags).sort()).toEqual(["project", "task"]);
659
+
660
+ const result = await store.renameTag("task", "todo");
661
+ expect(result).toMatchObject({ indexed_field_declarers_updated: 1 });
662
+
663
+ const afterRow = db
664
+ .prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
665
+ .get("status") as { declarer_tags: string };
666
+ const declarers = JSON.parse(afterRow.declarer_tags) as string[];
667
+ expect(declarers).toContain("todo");
668
+ expect(declarers).toContain("project");
669
+ expect(declarers).not.toContain("task");
670
+ });
671
+
672
+ it("12. rewrites `_tags/<path>` config-note paths via rewriteTagConfigPath (#275 fold N3)", async () => {
673
+ await store.upsertTagRecord("old", {});
674
+ const root = await store.createNote("root config", { path: "_tags/old" });
675
+ const sub = await store.createNote("sub config", { path: "_tags/old/nested/leaf" });
676
+
677
+ const result = await store.renameTag("old", "new");
678
+ expect(result).toMatchObject({ paths_renamed: 2 });
679
+
680
+ const rootFresh = await store.getNote(root.id);
681
+ expect(rootFresh?.path).toBe("_tags/new");
682
+
683
+ const subFresh = await store.getNote(sub.id);
684
+ expect(subFresh?.path).toBe("_tags/new/nested/leaf");
685
+ });
686
+
687
+ it("14. sub-tag discovery escapes LIKE wildcards — `task_` rename doesn't pull `taskX/sub` into the cascade (#275 re-review)", async () => {
688
+ // Pre-fold: the discovery query was `LIKE 'task_/%'` which matches
689
+ // `taskX/sub` because `_` is a single-char wildcard. `taskX/sub`
690
+ // would then enter `renames` and get rewritten to `<new>/sub` — a
691
+ // write the caller never asked for.
692
+ await store.upsertTagRecord("task_", {});
693
+ await store.upsertTagRecord("taskX/sub", {});
694
+ const stray = await store.createNote("stray", { tags: ["taskX/sub"] });
695
+
696
+ const result = await store.renameTag("task_", "todo_");
697
+ // Only the actual root rename — no spurious sub-tag pulled in.
698
+ expect(result).toMatchObject({ sub_tags_renamed: 0 });
699
+
700
+ expect(await store.getTagRecord("task_")).toBeNull();
701
+ expect(await store.getTagRecord("todo_")).toBeTruthy();
702
+
703
+ // `taskX/sub` is untouched: row still present, the note tagged with
704
+ // it still carries the original tag.
705
+ expect(await store.getTagRecord("taskX/sub")).toBeTruthy();
706
+ expect((await store.getNote(stray.id))!.tags).toEqual(["taskX/sub"]);
707
+ });
708
+
709
+ it("13. LIKE wildcard escape — a tag literally named `task_` doesn't false-match `taskX` (#275 fold N1)", async () => {
710
+ // `task_` and `taskX` are unrelated tags. Pre-fold N1 the LIKE
711
+ // pre-filter would have considered `taskX` rows as candidates for
712
+ // `task_`'s rewrite (LIKE `%"task_"%` matches `"taskX"` in JSON
713
+ // because `_` is a single-char wildcard). The cascade's downstream
714
+ // remapJsonArray would have rejected the row, so no data
715
+ // corruption — but the wasted scan + bad hygiene is what fold N1
716
+ // closes. This test pins the behavior end-to-end.
717
+ await store.upsertTagRecord("task_", {});
718
+ await store.upsertTagRecord("taskX", {});
719
+ await store.upsertTagRecord("voice", {
720
+ parent_names: ["taskX"],
721
+ });
722
+
723
+ await store.renameTag("task_", "renamed_task");
724
+
725
+ // taskX-rooted parent_names must be untouched — the escape stops
726
+ // taskX from being a candidate for the `task_` rewrite.
727
+ const voice = await store.getTagRecord("voice");
728
+ expect(voice?.parent_names).toEqual(["taskX"]);
729
+ // Sanity: the actual rename did happen.
730
+ expect(await store.getTagRecord("task_")).toBeNull();
731
+ expect(await store.getTagRecord("renamed_task")).toBeTruthy();
732
+ });
733
+ });
734
+
421
735
  describe("mergeTags", async () => {
422
736
  it("retags every note from every source onto target and drops sources", async () => {
423
737
  const n1 = await store.createNote("A", { tags: ["v1"] });
@@ -775,6 +1089,58 @@ describe("queryNotes", async () => {
775
1089
  }) as any[];
776
1090
  expect(results.map((n) => n.content)).toEqual(["recent email"]);
777
1091
  });
1092
+
1093
+ // ---- updated_at filter (vault#285 friction point 1.5) ----
1094
+ //
1095
+ // Incremental-rebuild flows ask "what changed since X." Like `created_at`,
1096
+ // `updated_at` is a real column on `notes` (no indexed-field gate), but
1097
+ // it tracks the *last write* rather than ingestion time. SSGs paginate
1098
+ // against it; sync clients use it as a high-watermark cursor.
1099
+ it("dateFilter on updated_at routes to the n.updated_at column (vault#285 1.5)", async () => {
1100
+ // Two notes; only one is later modified. The filter should pick up the
1101
+ // modification time, not the original creation time.
1102
+ const a = await store.createNote("untouched", { created_at: "2026-01-15T00:00:00.000Z" });
1103
+ const b = await store.createNote("modified-later", { created_at: "2026-01-20T00:00:00.000Z" });
1104
+ // Mutate b to bump its updated_at into a window that excludes a.
1105
+ await store.updateNote(b.id, { append: " edit" });
1106
+
1107
+ // Pin each note's updated_at deterministically so the assertion isn't
1108
+ // racing real wall-clock writes from the test harness.
1109
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1110
+ .run("2026-01-15T00:00:00.000Z", a.id);
1111
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1112
+ .run("2026-04-25T00:00:00.000Z", b.id);
1113
+
1114
+ const results = await store.queryNotes({
1115
+ dateFilter: { field: "updated_at", from: "2026-04-01" },
1116
+ });
1117
+ expect(results.map((n) => n.content)).toEqual(["modified-later edit"]);
1118
+ });
1119
+
1120
+ it("dateFilter on updated_at requires no indexed-field declaration", async () => {
1121
+ // `updated_at` is a recognized real column — must not hit the
1122
+ // requireIndexedField gate that fires for arbitrary metadata fields.
1123
+ await store.createNote("x");
1124
+ // Should not throw.
1125
+ const results = await store.queryNotes({
1126
+ dateFilter: { field: "updated_at", from: "1970-01-01" },
1127
+ });
1128
+ expect(Array.isArray(results)).toBe(true);
1129
+ });
1130
+
1131
+ it("dateFilter on updated_at honors the upper-bound exclusive `to`", async () => {
1132
+ const a = await store.createNote("inside-window");
1133
+ const b = await store.createNote("after-window");
1134
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1135
+ .run("2026-04-25T00:00:00.000Z", a.id);
1136
+ db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
1137
+ .run("2026-05-15T00:00:00.000Z", b.id);
1138
+
1139
+ const results = await store.queryNotes({
1140
+ dateFilter: { field: "updated_at", from: "2026-04-01", to: "2026-05-01" },
1141
+ });
1142
+ expect(results.map((n) => n.content)).toEqual(["inside-window"]);
1143
+ });
778
1144
  });
779
1145
 
780
1146
  it("sorts ascending and descending", async () => {
@@ -1161,16 +1527,22 @@ describe("MCP tools", async () => {
1161
1527
  expect(names).toContain("list-tags");
1162
1528
  expect(names).toContain("update-tag");
1163
1529
  expect(names).toContain("delete-tag");
1164
- expect(names).toContain("list-note-schemas");
1165
- expect(names).toContain("update-note-schema");
1166
- expect(names).toContain("delete-note-schema");
1167
- expect(names).toContain("list-schema-mappings");
1168
- expect(names).toContain("set-schema-mapping");
1169
- expect(names).toContain("delete-schema-mapping");
1170
1530
  expect(names).toContain("find-path");
1171
- expect(names).toContain("synthesize-notes");
1172
1531
  expect(names).toContain("vault-info");
1173
- expect(tools).toHaveLength(16);
1532
+ // Six note-schema tools (list/update/delete-note-schema +
1533
+ // list/set/delete-schema-mapping) retired in v17 — the standalone
1534
+ // note_schemas + schema_mappings subsystem was a parallel path to
1535
+ // tags.fields with zero operator usage. See vault#267.
1536
+ expect(names).not.toContain("list-note-schemas");
1537
+ expect(names).not.toContain("update-note-schema");
1538
+ expect(names).not.toContain("delete-note-schema");
1539
+ expect(names).not.toContain("list-schema-mappings");
1540
+ expect(names).not.toContain("set-schema-mapping");
1541
+ expect(names).not.toContain("delete-schema-mapping");
1542
+ // synthesize-notes retired in v17 — replicable with query-notes(near=) +
1543
+ // find-path + agent-side aggregation. See vault#268.
1544
+ expect(names).not.toContain("synthesize-notes");
1545
+ expect(tools).toHaveLength(9);
1174
1546
  });
1175
1547
 
1176
1548
  it("create-note tool works", async () => {
@@ -1391,6 +1763,96 @@ describe("MCP tools", async () => {
1391
1763
  expect((await store.getNote(note.id))!.content).toBe("Test");
1392
1764
  });
1393
1765
 
1766
+ // ---- include_content response-shape opt-out (vault#285 friction point 2.response) ----
1767
+ //
1768
+ // Default behavior is unchanged: full Note is returned with `content`.
1769
+ // Setting `include_content: false` swaps in the lean NoteIndex shape
1770
+ // (drops content, adds byteSize + preview). Cuts the response cost on
1771
+ // small-edit / large-note workflows.
1772
+ describe("update-note include_content", () => {
1773
+ it("defaults to full Note (back-compat)", async () => {
1774
+ const note = await store.createNote("Original body", { path: "x" });
1775
+ const tools = generateMcpTools(store);
1776
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1777
+ const result = await updateNote.execute({
1778
+ id: note.id,
1779
+ content: "Replaced body",
1780
+ force: true,
1781
+ }) as any;
1782
+ expect(result.content).toBe("Replaced body");
1783
+ // Index-only fields must NOT appear on the back-compat shape.
1784
+ expect(result.byteSize).toBeUndefined();
1785
+ expect(result.preview).toBeUndefined();
1786
+ });
1787
+
1788
+ it("include_content: false returns the lean NoteIndex shape", async () => {
1789
+ const longBody = "a".repeat(5_000);
1790
+ const note = await store.createNote(longBody, { path: "big-note" });
1791
+ const tools = generateMcpTools(store);
1792
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1793
+ const result = await updateNote.execute({
1794
+ id: note.id,
1795
+ append: " edit",
1796
+ include_content: false,
1797
+ }) as any;
1798
+ // No content payload — that's the whole point of the opt-out.
1799
+ expect(result.content).toBeUndefined();
1800
+ // Index fields present.
1801
+ expect(typeof result.byteSize).toBe("number");
1802
+ expect(result.byteSize).toBe(5_000 + 5); // original + " edit"
1803
+ expect(typeof result.preview).toBe("string");
1804
+ expect(result.preview.length).toBeGreaterThan(0);
1805
+ expect(result.id).toBe(note.id);
1806
+ expect(result.path).toBe("big-note");
1807
+ expect(result.updatedAt).toBeTruthy();
1808
+ });
1809
+
1810
+ it("include_content: false applies uniformly across batch responses", async () => {
1811
+ await store.createNote("A", { id: "a", path: "a" });
1812
+ await store.createNote("B", { id: "b", path: "b" });
1813
+ const tools = generateMcpTools(store);
1814
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1815
+ const result = await updateNote.execute({
1816
+ include_content: false,
1817
+ notes: [
1818
+ { id: "a", content: "A v2", force: true },
1819
+ { id: "b", append: " v2" },
1820
+ ],
1821
+ }) as any[];
1822
+ expect(result).toHaveLength(2);
1823
+ for (const item of result) {
1824
+ expect(item.content).toBeUndefined();
1825
+ expect(typeof item.byteSize).toBe("number");
1826
+ expect(typeof item.preview).toBe("string");
1827
+ }
1828
+ });
1829
+
1830
+ it("include_content: false preserves validation_status when present", async () => {
1831
+ // Declare a tag schema with an indexed `priority` field that constrains
1832
+ // values; then write a note whose metadata violates the schema, so
1833
+ // attachValidationStatus has something to surface.
1834
+ await store.upsertTagSchema("task", {
1835
+ description: "tasks",
1836
+ fields: {
1837
+ priority: { type: "string", enum: ["low", "med", "high"], indexed: false },
1838
+ },
1839
+ });
1840
+ await store.createNote("a task", { id: "t1", tags: ["task"] });
1841
+ const tools = generateMcpTools(store);
1842
+ const updateNote = tools.find((t) => t.name === "update-note")!;
1843
+ const result = await updateNote.execute({
1844
+ id: "t1",
1845
+ metadata: { priority: "URGENT" }, // not in enum — generates a warning
1846
+ include_content: false,
1847
+ force: true,
1848
+ }) as any;
1849
+ expect(result.content).toBeUndefined();
1850
+ expect(result.validation_status).toBeTruthy();
1851
+ expect(Array.isArray(result.validation_status.warnings)).toBe(true);
1852
+ expect(result.validation_status.warnings.length).toBeGreaterThan(0);
1853
+ });
1854
+ });
1855
+
1394
1856
  it("update-note force:true bypasses precondition and mutates unconditionally", async () => {
1395
1857
  const note = await store.createNote("First");
1396
1858
  const tools = generateMcpTools(store);
@@ -2205,183 +2667,6 @@ describe("MCP tools", async () => {
2205
2667
  expect(result.relationships).toEqual(["mentions", "related-to"]);
2206
2668
  });
2207
2669
 
2208
- // ---- synthesize-notes ----
2209
-
2210
- it("synthesize-notes rejects when neither anchor nor query is supplied", async () => {
2211
- const tools = generateMcpTools(store);
2212
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2213
- const result = await synth.execute({}) as any;
2214
- expect(result.error).toMatch(/at least one of `anchor` or `query`/);
2215
- });
2216
-
2217
- it("synthesize-notes rejects an unknown anchor", async () => {
2218
- const tools = generateMcpTools(store);
2219
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2220
- const result = await synth.execute({ anchor: "does-not-exist" }) as any;
2221
- expect(result.error).toMatch(/Anchor note not found/);
2222
- });
2223
-
2224
- it("synthesize-notes returns anchor + linked neighbors ranked", async () => {
2225
- await store.createNote("Hub note", { id: "hub", tags: ["topic"] });
2226
- await store.createNote("Direct neighbor", { id: "n1" });
2227
- await store.createNote("Two-hop neighbor", { id: "n2" });
2228
- await store.createNote("Unrelated", { id: "u1" });
2229
- await store.createLink("hub", "n1", "mentions");
2230
- await store.createLink("n1", "n2", "mentions");
2231
-
2232
- const tools = generateMcpTools(store);
2233
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2234
- const result = await synth.execute({ anchor: "hub", depth: 2 }) as any;
2235
-
2236
- const ids = result.notes.map((n: any) => n.id);
2237
- expect(ids).toContain("hub");
2238
- expect(ids).toContain("n1");
2239
- expect(ids).toContain("n2");
2240
- expect(ids).not.toContain("u1");
2241
- // Anchor ranks first; one-hop beats two-hop.
2242
- expect(ids[0]).toBe("hub");
2243
- expect(ids.indexOf("n1")).toBeLessThan(ids.indexOf("n2"));
2244
- expect(result.notes[0].sources).toContain("anchor");
2245
- expect(result.notes[0].distance).toBe(0);
2246
- expect(result.topic.anchor).toEqual({ id: "hub", path: null });
2247
- });
2248
-
2249
- it("synthesize-notes returns FTS hits when only query is supplied", async () => {
2250
- await store.createNote("Octopus thoughts: ink and color", { id: "o1" });
2251
- await store.createNote("Squid thoughts: deep blue", { id: "s1" });
2252
- await store.createNote("Recipe for octopus salad", { id: "o2" });
2253
-
2254
- const tools = generateMcpTools(store);
2255
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2256
- const result = await synth.execute({ query: "octopus" }) as any;
2257
-
2258
- const ids = result.notes.map((n: any) => n.id);
2259
- expect(ids).toContain("o1");
2260
- expect(ids).toContain("o2");
2261
- expect(ids).not.toContain("s1");
2262
- expect(result.notes[0].sources).toContain("search");
2263
- expect(result.notes[0]).toHaveProperty("fts_rank");
2264
- expect(result.topic.query).toBe("octopus");
2265
- });
2266
-
2267
- it("synthesize-notes applies scope.tags filter (any-match)", async () => {
2268
- await store.createNote("Anchor", { id: "a" });
2269
- await store.createNote("Tagged neighbor", { id: "t1", tags: ["alpha"] });
2270
- await store.createNote("Other neighbor", { id: "t2", tags: ["beta"] });
2271
- await store.createLink("a", "t1", "mentions");
2272
- await store.createLink("a", "t2", "mentions");
2273
-
2274
- const tools = generateMcpTools(store);
2275
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2276
- const result = await synth.execute({ anchor: "a", scope: { tags: ["alpha"] } }) as any;
2277
-
2278
- const ids = result.notes.map((n: any) => n.id);
2279
- expect(ids).toContain("t1");
2280
- expect(ids).not.toContain("t2");
2281
- expect(ids).not.toContain("a"); // anchor itself has no tags
2282
- });
2283
-
2284
- it("synthesize-notes applies scope.path prefix filter", async () => {
2285
- await store.createNote("Anchor", { id: "a" });
2286
- await store.createNote("In scope", { id: "p1", path: "Projects/Alpha/notes" });
2287
- await store.createNote("Out of scope", { id: "p2", path: "People/Bob" });
2288
- await store.createLink("a", "p1", "mentions");
2289
- await store.createLink("a", "p2", "mentions");
2290
-
2291
- const tools = generateMcpTools(store);
2292
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2293
- const result = await synth.execute({ anchor: "a", scope: { path: "projects/" } }) as any;
2294
-
2295
- const ids = result.notes.map((n: any) => n.id);
2296
- expect(ids).toContain("p1");
2297
- expect(ids).not.toContain("p2");
2298
- });
2299
-
2300
- it("synthesize-notes respects limit and sets truncated flag", async () => {
2301
- await store.createNote("Anchor", { id: "anchor" });
2302
- for (let i = 0; i < 5; i++) {
2303
- await store.createNote(`Neighbor ${i}`, { id: `n${i}` });
2304
- await store.createLink("anchor", `n${i}`, "mentions");
2305
- }
2306
-
2307
- const tools = generateMcpTools(store);
2308
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2309
- const result = await synth.execute({ anchor: "anchor", limit: 3 }) as any;
2310
-
2311
- expect(result.notes).toHaveLength(3);
2312
- expect(result.truncated).toBe(true);
2313
- });
2314
-
2315
- it("synthesize-notes connections include only links between returned notes", async () => {
2316
- await store.createNote("Anchor", { id: "a" });
2317
- await store.createNote("In set", { id: "b" });
2318
- await store.createNote("Excluded", { id: "c" });
2319
- await store.createLink("a", "b", "mentions");
2320
- await store.createLink("a", "c", "mentions");
2321
- await store.createLink("b", "c", "related-to");
2322
-
2323
- const tools = generateMcpTools(store);
2324
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2325
- const result = await synth.execute({ anchor: "a", depth: 1, limit: 2 }) as any;
2326
-
2327
- const ids = new Set(result.notes.map((n: any) => n.id));
2328
- for (const c of result.connections) {
2329
- expect(ids.has(c.source)).toBe(true);
2330
- expect(ids.has(c.target)).toBe(true);
2331
- }
2332
- expect(result.connections.some((c: any) => c.target === "c")).toBe(false);
2333
- });
2334
-
2335
- it("synthesize-notes timeline orders oldest → newest", async () => {
2336
- await store.createNote("Old", { id: "old", created_at: "2024-01-01T00:00:00.000Z" });
2337
- await store.createNote("Mid", { id: "mid", created_at: "2025-01-01T00:00:00.000Z" });
2338
- await store.createNote("New", { id: "new", created_at: "2026-01-01T00:00:00.000Z" });
2339
- await store.createLink("new", "mid", "mentions");
2340
- await store.createLink("mid", "old", "mentions");
2341
-
2342
- const tools = generateMcpTools(store);
2343
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2344
- const result = await synth.execute({ anchor: "new", depth: 2 }) as any;
2345
-
2346
- const ids = result.timeline.map((t: any) => t.id);
2347
- expect(ids).toEqual(["old", "mid", "new"]);
2348
- });
2349
-
2350
- it("synthesize-notes tag distribution counts tags across results", async () => {
2351
- await store.createNote("Anchor", { id: "a", tags: ["alpha", "beta"] });
2352
- await store.createNote("N1", { id: "n1", tags: ["alpha"] });
2353
- await store.createNote("N2", { id: "n2", tags: ["alpha", "gamma"] });
2354
- await store.createLink("a", "n1", "mentions");
2355
- await store.createLink("a", "n2", "mentions");
2356
-
2357
- const tools = generateMcpTools(store);
2358
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2359
- const result = await synth.execute({ anchor: "a" }) as any;
2360
-
2361
- const tagMap = new Map(result.tags.map((t: any) => [t.name, t.count]));
2362
- expect(tagMap.get("alpha")).toBe(3);
2363
- expect(tagMap.get("beta")).toBe(1);
2364
- expect(tagMap.get("gamma")).toBe(1);
2365
- expect(result.tags[0].name).toBe("alpha");
2366
- });
2367
-
2368
- it("synthesize-notes include_content controls snippet vs full body", async () => {
2369
- const longBody = "x".repeat(500);
2370
- await store.createNote(longBody, { id: "long", path: "Long" });
2371
-
2372
- const tools = generateMcpTools(store);
2373
- const synth = tools.find((t) => t.name === "synthesize-notes")!;
2374
-
2375
- const snippetResult = await synth.execute({ anchor: "long" }) as any;
2376
- expect(snippetResult.notes[0]).toHaveProperty("snippet");
2377
- expect(snippetResult.notes[0]).not.toHaveProperty("content");
2378
- expect(snippetResult.notes[0].snippet.length).toBeLessThanOrEqual(200);
2379
-
2380
- const fullResult = await synth.execute({ anchor: "long", include_content: true }) as any;
2381
- expect(fullResult.notes[0].content).toBe(longBody);
2382
- expect(fullResult.notes[0]).not.toHaveProperty("snippet");
2383
- });
2384
-
2385
2670
  it("create-note via store triggers wikilink sync", async () => {
2386
2671
  const tools = generateMcpTools(store);
2387
2672
  const createNote = tools.find((t) => t.name === "create-note")!;
@@ -3184,64 +3469,42 @@ describe("tag hierarchy (tags.parent_names)", async () => {
3184
3469
  });
3185
3470
 
3186
3471
  // ---------------------------------------------------------------------------
3187
- // Note schemastable-driven (post-v15: `note_schemas` + `schema_mappings`)
3472
+ // Schema validation — driven by `tags.fields` (post-v17, vault#267)
3188
3473
  // ---------------------------------------------------------------------------
3189
3474
  // Originally written against the `_schemas/<name>` + `_schema_defaults`
3190
- // notes-as-config convention (issue #177). Rewritten for vault#246: the
3191
- // authoring surface is now `store.upsertNoteSchema` + `store.setSchemaMapping`
3192
- // and the legacy notes are inert (left in place as audit trail).
3475
+ // notes-as-config convention (issue #177); rewritten for vault#246 against
3476
+ // the standalone `note_schemas` + `schema_mappings` tables; rewritten again
3477
+ // for vault#267 against `tags.fields` after the standalone subsystem retired.
3478
+ // The validation surface is intentionally smaller now — schemas are tag-axis
3479
+ // only (no path_prefix) and advisory only (no `required` concept).
3193
3480
 
3194
- describe("note schemas (note_schemas + schema_mappings)", async () => {
3195
- it("returns no validation_status when no schemas are configured", async () => {
3481
+ describe("schema validation (tags.fields)", async () => {
3482
+ it("returns no validation_status when no tag declares fields", async () => {
3196
3483
  const tools = generateMcpTools(store);
3197
3484
  const create = tools.find((t) => t.name === "create-note")!;
3198
3485
  const result = await create.execute({ content: "plain note" }) as any;
3199
3486
  expect(result.validation_status).toBeUndefined();
3200
3487
  });
3201
3488
 
3202
- it("attaches validation_status when a schema matches by tag", async () => {
3203
- await store.upsertNoteSchema("task", {
3204
- description: "A task",
3205
- fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
3206
- required: ["priority"],
3207
- });
3208
- await store.setSchemaMapping("task", "tag", "task");
3209
-
3210
- const tools = generateMcpTools(store);
3211
- const create = tools.find((t) => t.name === "create-note")!;
3212
- const result = await create.execute({ content: "do thing", tags: ["task"] }) as any;
3213
-
3214
- expect(result.validation_status).toBeTruthy();
3215
- expect(result.validation_status.schemas).toEqual(["task"]);
3216
- expect(result.validation_status.warnings.length).toBe(1);
3217
- expect(result.validation_status.warnings[0].reason).toBe("missing_required");
3218
- expect(result.validation_status.warnings[0].field).toBe("priority");
3219
- });
3220
-
3221
- it("attaches validation_status when a schema matches by path prefix", async () => {
3222
- await store.upsertNoteSchema("journal-entry", {
3223
- fields: { mood: { type: "string" } },
3224
- required: ["mood"],
3489
+ it("returns no validation_status when no tag on the note declares fields", async () => {
3490
+ // A different tag has fields, but the note isn't tagged with it.
3491
+ await store.upsertTagSchema("task", {
3492
+ fields: { priority: { type: "string" } },
3225
3493
  });
3226
- await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
3227
3494
 
3228
3495
  const tools = generateMcpTools(store);
3229
3496
  const create = tools.find((t) => t.name === "create-note")!;
3230
- const result = await create.execute({ content: "today", path: "journal/2026-04-29" }) as any;
3231
-
3232
- expect(result.validation_status.schemas).toEqual(["journal-entry"]);
3233
- expect(result.validation_status.warnings[0].reason).toBe("missing_required");
3497
+ const result = await create.execute({ content: "plain note", tags: ["other"] }) as any;
3498
+ expect(result.validation_status).toBeUndefined();
3234
3499
  });
3235
3500
 
3236
- it("validation passes (empty warnings) when required fields are present and types match", async () => {
3237
- await store.upsertNoteSchema("task", {
3501
+ it("validation passes (empty warnings) when fields match types", async () => {
3502
+ await store.upsertTagSchema("task", {
3238
3503
  fields: {
3239
3504
  priority: { type: "string", enum: ["high", "medium", "low"] },
3240
3505
  done: { type: "boolean" },
3241
3506
  },
3242
- required: ["priority"],
3243
3507
  });
3244
- await store.setSchemaMapping("task", "tag", "task");
3245
3508
 
3246
3509
  const tools = generateMcpTools(store);
3247
3510
  const create = tools.find((t) => t.name === "create-note")!;
@@ -3256,10 +3519,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3256
3519
  });
3257
3520
 
3258
3521
  it("type_mismatch warning when a field's value is the wrong type", async () => {
3259
- await store.upsertNoteSchema("task", {
3522
+ await store.upsertTagSchema("task", {
3260
3523
  fields: { priority: { type: "string" }, done: { type: "boolean" } },
3261
3524
  });
3262
- await store.setSchemaMapping("task", "tag", "task");
3263
3525
 
3264
3526
  const tools = generateMcpTools(store);
3265
3527
  const create = tools.find((t) => t.name === "create-note")!;
@@ -3275,10 +3537,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3275
3537
  });
3276
3538
 
3277
3539
  it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
3278
- await store.upsertNoteSchema("task", {
3540
+ await store.upsertTagSchema("task", {
3279
3541
  fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
3280
3542
  });
3281
- await store.setSchemaMapping("task", "tag", "task");
3282
3543
 
3283
3544
  const tools = generateMcpTools(store);
3284
3545
  const create = tools.find((t) => t.name === "create-note")!;
@@ -3292,12 +3553,17 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3292
3553
  });
3293
3554
 
3294
3555
  it("validation never blocks the write — note exists with warnings attached", async () => {
3295
- await store.upsertNoteSchema("task", { required: ["priority"] });
3296
- await store.setSchemaMapping("task", "tag", "task");
3556
+ await store.upsertTagSchema("task", {
3557
+ fields: { priority: { type: "boolean" } },
3558
+ });
3297
3559
 
3298
3560
  const tools = generateMcpTools(store);
3299
3561
  const create = tools.find((t) => t.name === "create-note")!;
3300
- const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3562
+ const result = await create.execute({
3563
+ content: "x",
3564
+ tags: ["task"],
3565
+ metadata: { priority: "high" }, // wrong type, but the write still lands
3566
+ }) as any;
3301
3567
 
3302
3568
  expect(result.id).toBeTruthy();
3303
3569
  expect(result.validation_status.warnings.length).toBe(1);
@@ -3308,11 +3574,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3308
3574
  });
3309
3575
 
3310
3576
  it("update-note also surfaces validation_status", async () => {
3311
- await store.upsertNoteSchema("task", {
3577
+ await store.upsertTagSchema("task", {
3312
3578
  fields: { priority: { type: "string", enum: ["high", "low"] } },
3313
- required: ["priority"],
3314
3579
  });
3315
- await store.setSchemaMapping("task", "tag", "task");
3316
3580
  const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
3317
3581
 
3318
3582
  const tools = generateMcpTools(store);
@@ -3326,98 +3590,83 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3326
3590
  expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
3327
3591
  });
3328
3592
 
3329
- it("cache invalidates when a schema mapping is removed", async () => {
3330
- await store.upsertNoteSchema("task", { required: ["a"] });
3331
- await store.setSchemaMapping("task", "tag", "task");
3332
-
3333
- const tools = generateMcpTools(store);
3334
- const create = tools.find((t) => t.name === "create-note")!;
3335
- let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3336
- expect(result.validation_status.warnings[0].field).toBe("a");
3337
-
3338
- await store.deleteSchemaMapping("task", "tag", "task");
3339
-
3340
- result = await create.execute({ content: "y", tags: ["task"] }) as any;
3341
- expect(result.validation_status).toBeUndefined();
3342
- });
3343
-
3344
- it("cache invalidates when a schema is updated", async () => {
3345
- await store.upsertNoteSchema("task", { required: ["a"] });
3346
- await store.setSchemaMapping("task", "tag", "task");
3593
+ it("cache invalidates when a tag schema is updated", async () => {
3594
+ await store.upsertTagSchema("task", {
3595
+ fields: { priority: { type: "boolean" } },
3596
+ });
3347
3597
 
3348
3598
  const tools = generateMcpTools(store);
3349
3599
  const create = tools.find((t) => t.name === "create-note")!;
3350
- let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3351
- expect(result.validation_status.warnings[0].field).toBe("a");
3600
+ let result = await create.execute({
3601
+ content: "x",
3602
+ tags: ["task"],
3603
+ metadata: { priority: "high" },
3604
+ }) as any;
3605
+ expect(result.validation_status.warnings[0].field).toBe("priority");
3606
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3352
3607
 
3353
- // Re-declare with a different required field; cache must reflect the change.
3354
- await store.upsertNoteSchema("task", { required: ["b"] });
3355
- result = await create.execute({ content: "y", tags: ["task"] }) as any;
3356
- expect(result.validation_status.warnings[0].field).toBe("b");
3608
+ // Re-declare with a string type; cache must reflect the change.
3609
+ await store.upsertTagSchema("task", {
3610
+ fields: { priority: { type: "string" } },
3611
+ });
3612
+ result = await create.execute({
3613
+ content: "y",
3614
+ tags: ["task"],
3615
+ metadata: { priority: "high" },
3616
+ }) as any;
3617
+ expect(result.validation_status.warnings).toEqual([]);
3357
3618
  });
3358
3619
 
3359
- it("cache invalidates when a schema is deleted (cascades mappings)", async () => {
3360
- await store.upsertNoteSchema("task", { required: ["a"] });
3361
- await store.setSchemaMapping("task", "tag", "task");
3620
+ it("cache invalidates when a tag schema is deleted", async () => {
3621
+ await store.upsertTagSchema("task", {
3622
+ fields: { priority: { type: "boolean" } },
3623
+ });
3362
3624
 
3363
3625
  const tools = generateMcpTools(store);
3364
3626
  const create = tools.find((t) => t.name === "create-note")!;
3365
- let result = await create.execute({ content: "x", tags: ["task"] }) as any;
3366
- expect(result.validation_status.warnings[0].field).toBe("a");
3627
+ let result = await create.execute({
3628
+ content: "x",
3629
+ tags: ["task"],
3630
+ metadata: { priority: "high" },
3631
+ }) as any;
3632
+ expect(result.validation_status.warnings.length).toBe(1);
3367
3633
 
3368
- await store.deleteNoteSchema("task");
3369
- // FK CASCADE drops the mapping too.
3370
- expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
3634
+ await store.deleteTagSchema("task");
3371
3635
 
3372
- result = await create.execute({ content: "y", tags: ["task"] }) as any;
3636
+ result = await create.execute({
3637
+ content: "y",
3638
+ tags: ["task"],
3639
+ metadata: { priority: "high" },
3640
+ }) as any;
3373
3641
  expect(result.validation_status).toBeUndefined();
3374
3642
  });
3375
3643
 
3376
- it("longest path prefix wins when multiple match", async () => {
3377
- await store.upsertNoteSchema("journal-day", { required: ["mood"] });
3378
- await store.upsertNoteSchema("journal-broad", { required: ["topic"] });
3379
- await store.setSchemaMapping("journal-broad", "path_prefix", "journal/");
3380
- await store.setSchemaMapping("journal-day", "path_prefix", "journal/2026/");
3381
-
3382
- const tools = generateMcpTools(store);
3383
- const create = tools.find((t) => t.name === "create-note")!;
3384
- const result = await create.execute({ content: "x", path: "journal/2026/april" }) as any;
3385
- expect(result.validation_status.schemas).toEqual(["journal-day"]);
3386
- });
3387
-
3388
- it("multiple schemas can apply (path + tag combine warnings)", async () => {
3389
- await store.upsertNoteSchema("journal-entry", { required: ["mood"] });
3390
- await store.upsertNoteSchema("task", { required: ["priority"] });
3391
- await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
3392
- await store.setSchemaMapping("task", "tag", "task");
3644
+ it("multiple tag schemas combine warnings", async () => {
3645
+ await store.upsertTagSchema("task", {
3646
+ fields: { priority: { type: "string" } },
3647
+ });
3648
+ await store.upsertTagSchema("project", {
3649
+ fields: { status: { type: "string", enum: ["active", "done"] } },
3650
+ });
3393
3651
 
3394
3652
  const tools = generateMcpTools(store);
3395
3653
  const create = tools.find((t) => t.name === "create-note")!;
3396
3654
  const result = await create.execute({
3397
3655
  content: "x",
3398
- path: "journal/today",
3399
- tags: ["task"],
3656
+ tags: ["task", "project"],
3657
+ metadata: { priority: 7, status: "WIP" }, // bad: wrong type, bad enum
3400
3658
  }) as any;
3401
3659
 
3402
- expect(result.validation_status.schemas.sort()).toEqual(["journal-entry", "task"]);
3660
+ expect(result.validation_status.schemas.sort()).toEqual(["project", "task"]);
3403
3661
  expect(result.validation_status.warnings.length).toBe(2);
3404
3662
  });
3405
3663
 
3406
- it("a schema with no mappings is harmless (no validation)", async () => {
3407
- await store.upsertNoteSchema("orphan", { required: ["x"] });
3408
-
3409
- const tools = generateMcpTools(store);
3410
- const create = tools.find((t) => t.name === "create-note")!;
3411
- const result = await create.execute({ content: "anything" }) as any;
3412
- expect(result.validation_status).toBeUndefined();
3413
- });
3414
-
3415
- it("legacy `_schemas/<name>` notes are inert post-v15", async () => {
3664
+ it("legacy `_schemas/<name>` notes are inert post-v17", async () => {
3416
3665
  // The notes still write/read fine — they're just no longer interpreted
3417
- // as schema config. Nothing in note_schemas/schema_mappings → no validation.
3666
+ // as schema config. Nothing in tags.fields → no validation.
3418
3667
  await store.createNote("", {
3419
3668
  path: "_schemas/task",
3420
- metadata: { required: ["priority"] },
3669
+ metadata: { fields: { priority: { type: "string" } } },
3421
3670
  });
3422
3671
  await store.createNote("", {
3423
3672
  path: "_schema_defaults",
@@ -3429,82 +3678,499 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
3429
3678
  const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3430
3679
  expect(result.validation_status).toBeUndefined();
3431
3680
  });
3432
- });
3433
-
3434
- // ---------------------------------------------------------------------------
3435
- // note_schemas + schema_mappings — direct CRUD (vault#246)
3436
- // ---------------------------------------------------------------------------
3437
-
3438
- describe("note_schemas / schema_mappings CRUD", async () => {
3439
- it("upsertNoteSchema partial-merge: undefined preserves, null clears", async () => {
3440
- await store.upsertNoteSchema("task", {
3441
- description: "A task",
3442
- fields: { priority: { type: "string" } },
3443
- required: ["priority"],
3444
- });
3445
-
3446
- // description omitted → preserved
3447
- await store.upsertNoteSchema("task", { required: ["priority", "due"] });
3448
- let row = await store.getNoteSchema("task");
3449
- expect(row?.description).toBe("A task");
3450
- expect(row?.required).toEqual(["priority", "due"]);
3451
3681
 
3452
- // description null cleared
3453
- await store.upsertNoteSchema("task", { description: null });
3454
- row = await store.getNoteSchema("task");
3455
- expect(row?.description).toBeNull();
3456
- expect(row?.required).toEqual(["priority", "due"]); // still preserved
3457
- });
3458
-
3459
- it("empty `required: []` collapses to null", async () => {
3460
- await store.upsertNoteSchema("task", { required: ["a", "b"] });
3461
- await store.upsertNoteSchema("task", { required: [] });
3462
- const row = await store.getNoteSchema("task");
3463
- expect(row?.required).toBeNull();
3464
- });
3682
+ // vault#310 integer type validation. JSON has no separate integer
3683
+ // type, so a JSON number with zero fractional part (`5`, `5.0`) must
3684
+ // pass an `integer`-typed field. Pre-fix, the validator had no
3685
+ // `"integer"` case at all — falling through the switch returned
3686
+ // undefined and every integer-typed field warned `type_mismatch` on
3687
+ // legitimate values. Gitcoin Brain's drift detector emits JSON for
3688
+ // diffs; every `kpi: 3` triggered the false-positive and buried the
3689
+ // real warnings.
3465
3690
 
3466
- it("setSchemaMapping is idempotent (composite PK)", async () => {
3467
- await store.upsertNoteSchema("task", {});
3468
- await store.setSchemaMapping("task", "tag", "task");
3469
- await store.setSchemaMapping("task", "tag", "task"); // re-set same triple
3470
- const mappings = await store.listSchemaMappings({ schema_name: "task" });
3471
- expect(mappings.length).toBe(1);
3691
+ it("integer-typed field: JSON integer (5) passes (vault#310)", async () => {
3692
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3693
+ const tools = generateMcpTools(store);
3694
+ const create = tools.find((t) => t.name === "create-note")!;
3695
+ const result = await create.execute({
3696
+ content: "x",
3697
+ tags: ["kpi"],
3698
+ metadata: { count: 5 },
3699
+ }) as any;
3700
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3472
3701
  });
3473
3702
 
3474
- it("setSchemaMapping rejects unknown match_kind", async () => {
3475
- await store.upsertNoteSchema("task", {});
3476
- await expect(
3477
- store.setSchemaMapping("task", "BOGUS" as any, "task"),
3478
- ).rejects.toThrow(/match_kind/);
3703
+ it("integer-typed field: JSON `5.0` (zero-fractional) passes (vault#310)", async () => {
3704
+ // 5.0 is the canonical Gitcoin shape — JSON.parse decodes the
3705
+ // emitted JSON number as a JS Number; the JS Number for `5.0` is
3706
+ // identical to `5` so Number.isInteger reports true. This is the
3707
+ // load-bearing assertion for the Gitcoin drift-detector use case.
3708
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3709
+ const tools = generateMcpTools(store);
3710
+ const create = tools.find((t) => t.name === "create-note")!;
3711
+ const result = await create.execute({
3712
+ content: "x",
3713
+ tags: ["kpi"],
3714
+ metadata: { count: 5.0 },
3715
+ }) as any;
3716
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3479
3717
  });
3480
3718
 
3481
- it("setSchemaMapping fails (FK) when schema doesn't exist", async () => {
3482
- await expect(
3483
- store.setSchemaMapping("missing", "tag", "x"),
3484
- ).rejects.toThrow();
3719
+ it("integer-typed field: JSON `5.5` (non-zero fractional) warns type_mismatch (vault#310)", async () => {
3720
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3721
+ const tools = generateMcpTools(store);
3722
+ const create = tools.find((t) => t.name === "create-note")!;
3723
+ const result = await create.execute({
3724
+ content: "x",
3725
+ tags: ["kpi"],
3726
+ metadata: { count: 5.5 },
3727
+ }) as any;
3728
+ expect(result.validation_status.warnings.length).toBe(1);
3729
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3730
+ expect(result.validation_status.warnings[0].field).toBe("count");
3485
3731
  });
3486
3732
 
3487
- it("deleteNoteSchema cascades schema_mappings (FK ON DELETE CASCADE)", async () => {
3488
- await store.upsertNoteSchema("task", {});
3489
- await store.setSchemaMapping("task", "tag", "task");
3490
- await store.setSchemaMapping("task", "path_prefix", "Tasks/");
3491
- expect((await store.listSchemaMappings({ schema_name: "task" })).length).toBe(2);
3733
+ it("integer-typed field: string `\"5\"` warns type_mismatch (no string→number coercion) (vault#310)", async () => {
3734
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3735
+ const tools = generateMcpTools(store);
3736
+ const create = tools.find((t) => t.name === "create-note")!;
3737
+ const result = await create.execute({
3738
+ content: "x",
3739
+ tags: ["kpi"],
3740
+ metadata: { count: "5" },
3741
+ }) as any;
3742
+ expect(result.validation_status.warnings.length).toBe(1);
3743
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3744
+ });
3492
3745
 
3493
- expect(await store.deleteNoteSchema("task")).toBe(true);
3494
- expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
3495
- expect(await store.deleteNoteSchema("task")).toBe(false); // already gone
3746
+ it("integer-typed field: edge `5.0000000000001` warns type_mismatch (vault#310)", async () => {
3747
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3748
+ const tools = generateMcpTools(store);
3749
+ const create = tools.find((t) => t.name === "create-note")!;
3750
+ const result = await create.execute({
3751
+ content: "x",
3752
+ tags: ["kpi"],
3753
+ metadata: { count: 5.0000000000001 },
3754
+ }) as any;
3755
+ expect(result.validation_status.warnings.length).toBe(1);
3756
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3496
3757
  });
3497
3758
 
3498
- it("listSchemaMappings filters by schema_name and match_kind", async () => {
3499
- await store.upsertNoteSchema("a", {});
3500
- await store.upsertNoteSchema("b", {});
3501
- await store.setSchemaMapping("a", "tag", "a-tag");
3502
- await store.setSchemaMapping("a", "path_prefix", "A/");
3503
- await store.setSchemaMapping("b", "tag", "b-tag");
3759
+ it("integer-typed field: boolean warns type_mismatch (vault#310)", async () => {
3760
+ // Pin that boolean is rejected (Number.isInteger(true) returns
3761
+ // false, but extra coverage in case anyone "improves" the check
3762
+ // with a looser predicate later).
3763
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3764
+ const tools = generateMcpTools(store);
3765
+ const create = tools.find((t) => t.name === "create-note")!;
3766
+ const result = await create.execute({
3767
+ content: "x",
3768
+ tags: ["kpi"],
3769
+ metadata: { count: true },
3770
+ }) as any;
3771
+ expect(result.validation_status.warnings.length).toBe(1);
3772
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3773
+ });
3504
3774
 
3505
- expect((await store.listSchemaMappings({ schema_name: "a" })).length).toBe(2);
3506
- expect((await store.listSchemaMappings({ match_kind: "tag" })).length).toBe(2);
3507
- expect((await store.listSchemaMappings({ schema_name: "a", match_kind: "tag" })).length).toBe(1);
3775
+ // Note on NaN/Infinity: those values pass through
3776
+ // JSON.stringify as `null`, then the validator's null short-circuit
3777
+ // (schema-defaults.ts:327 "value === null → skip") filters them out
3778
+ // before reaching the type check. We can't observe them in
3779
+ // validation_status from this layer; a dedicated unit test against
3780
+ // `valueMatchesType` would catch the case at the inner boundary.
3781
+ // Pinned at the next layer down:
3782
+
3783
+ it("valueMatchesType('integer', ...) rejects NaN / Infinity (vault#310)", async () => {
3784
+ // Reach the unexported helper indirectly via validateNote on a
3785
+ // hand-built resolved-schemas + metadata where the value is the
3786
+ // actual NaN/Infinity (no JSON round-trip).
3787
+ const { validateNote, loadSchemaConfig } = await import("./schema-defaults.js");
3788
+ // Seed via the public surface, then load the resolved schemas
3789
+ // snapshot.
3790
+ await store.upsertTagSchema("k", { fields: { c: { type: "integer" } } });
3791
+ const resolved = loadSchemaConfig((store as any).db);
3792
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.NaN } })?.warnings[0]?.reason)
3793
+ .toBe("type_mismatch");
3794
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.POSITIVE_INFINITY } })?.warnings[0]?.reason)
3795
+ .toBe("type_mismatch");
3796
+ });
3797
+ });
3798
+
3799
+ // ---------------------------------------------------------------------------
3800
+ // Schema inheritance via parent_names + `_default` universal parent — vault#270
3801
+ // ---------------------------------------------------------------------------
3802
+
3803
+ describe("schema inheritance via parent_names (vault#270)", async () => {
3804
+ it("single-parent: child inherits parent's fields", async () => {
3805
+ await store.upsertTagRecord("work", {
3806
+ fields: { project: { type: "string" } },
3807
+ });
3808
+ await store.upsertTagRecord("task", {
3809
+ parent_names: ["work"],
3810
+ fields: { priority: { type: "string" } },
3811
+ });
3812
+
3813
+ const tools = generateMcpTools(store);
3814
+ const create = tools.find((t) => t.name === "create-note")!;
3815
+ const result = await create.execute({
3816
+ content: "x",
3817
+ tags: ["task"],
3818
+ metadata: { priority: 7, project: 42 }, // both wrong type
3819
+ }) as any;
3820
+
3821
+ expect(result.validation_status.warnings.length).toBe(2);
3822
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3823
+ expect(fields).toEqual(["priority", "project"]);
3824
+ });
3825
+
3826
+ it("multi-parent: child gets union of parents' fields", async () => {
3827
+ await store.upsertTagRecord("work", {
3828
+ fields: { project: { type: "string" } },
3829
+ });
3830
+ await store.upsertTagRecord("publication", {
3831
+ fields: { venue: { type: "string" } },
3832
+ });
3833
+ await store.upsertTagRecord("paper", {
3834
+ parent_names: ["work", "publication"],
3835
+ fields: { title: { type: "string" } },
3836
+ });
3837
+
3838
+ const tools = generateMcpTools(store);
3839
+ const create = tools.find((t) => t.name === "create-note")!;
3840
+ const result = await create.execute({
3841
+ content: "x",
3842
+ tags: ["paper"],
3843
+ metadata: { title: 1, project: 2, venue: 3 }, // all wrong type
3844
+ }) as any;
3845
+
3846
+ expect(result.validation_status.warnings.length).toBe(3);
3847
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3848
+ expect(fields).toEqual(["project", "title", "venue"]);
3849
+ });
3850
+
3851
+ it("diamond: A→B, A→C, B→D, C→D — D's field appears once", async () => {
3852
+ await store.upsertTagRecord("D", {
3853
+ fields: { d_field: { type: "string" } },
3854
+ });
3855
+ await store.upsertTagRecord("B", { parent_names: ["D"] });
3856
+ await store.upsertTagRecord("C", { parent_names: ["D"] });
3857
+ await store.upsertTagRecord("A", { parent_names: ["B", "C"] });
3858
+
3859
+ const tools = generateMcpTools(store);
3860
+ const create = tools.find((t) => t.name === "create-note")!;
3861
+ const result = await create.execute({
3862
+ content: "x",
3863
+ tags: ["A"],
3864
+ metadata: { d_field: 999 }, // wrong type
3865
+ }) as any;
3866
+
3867
+ expect(result.validation_status.warnings.length).toBe(1);
3868
+ expect(result.validation_status.warnings[0].field).toBe("d_field");
3869
+ expect(result.validation_status.warnings[0].schema).toBe("D");
3870
+ });
3871
+
3872
+ it("cycle: A→B, B→A — no infinite loop, both fields visible", async () => {
3873
+ await store.upsertTagRecord("A", {
3874
+ parent_names: ["B"],
3875
+ fields: { a_field: { type: "string" } },
3876
+ });
3877
+ await store.upsertTagRecord("B", {
3878
+ parent_names: ["A"],
3879
+ fields: { b_field: { type: "string" } },
3880
+ });
3881
+
3882
+ const tools = generateMcpTools(store);
3883
+ const create = tools.find((t) => t.name === "create-note")!;
3884
+ const result = await create.execute({
3885
+ content: "x",
3886
+ tags: ["A"],
3887
+ metadata: { a_field: 1, b_field: 2 }, // both wrong type
3888
+ }) as any;
3889
+
3890
+ expect(result.validation_status.warnings.length).toBe(2);
3891
+ const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
3892
+ expect(fields).toEqual(["a_field", "b_field"]);
3893
+ });
3894
+
3895
+ it("override: child's spec wins over parent's for the same field name", async () => {
3896
+ await store.upsertTagRecord("parent_tag", {
3897
+ fields: { status: { type: "string", enum: ["a", "b"] } },
3898
+ });
3899
+ await store.upsertTagRecord("child_tag", {
3900
+ parent_names: ["parent_tag"],
3901
+ fields: { status: { type: "string", enum: ["x", "y"] } },
3902
+ });
3903
+
3904
+ const tools = generateMcpTools(store);
3905
+ const create = tools.find((t) => t.name === "create-note")!;
3906
+ const result = await create.execute({
3907
+ content: "x",
3908
+ tags: ["child_tag"],
3909
+ metadata: { status: "x" }, // valid under child, invalid under parent
3910
+ }) as any;
3911
+
3912
+ // Child's spec wins → "x" passes the enum check.
3913
+ const enumWarnings = result.validation_status.warnings.filter(
3914
+ (w: any) => w.reason === "enum_mismatch",
3915
+ );
3916
+ expect(enumWarnings.length).toBe(0);
3917
+ // The conflict surfaces as a schema_conflict warning whose `schema` field
3918
+ // names the *winning* tag (child).
3919
+ const conflict = result.validation_status.warnings.find(
3920
+ (w: any) => w.reason === "schema_conflict",
3921
+ );
3922
+ expect(conflict).toBeDefined();
3923
+ expect(conflict.field).toBe("status");
3924
+ expect(conflict.schema).toBe("child_tag");
3925
+ });
3926
+
3927
+ it("conflict warning: two parents declare same field with different specs, first wins", async () => {
3928
+ await store.upsertTagRecord("task", {
3929
+ fields: { status: { type: "string", enum: ["todo", "doing", "done"] } },
3930
+ });
3931
+ await store.upsertTagRecord("publication", {
3932
+ fields: { status: { type: "string", enum: ["draft", "published"] } },
3933
+ });
3934
+ // parent_names order = ["task", "publication"] → task wins.
3935
+ await store.upsertTagRecord("article_task", {
3936
+ parent_names: ["task", "publication"],
3937
+ });
3938
+
3939
+ const tools = generateMcpTools(store);
3940
+ const create = tools.find((t) => t.name === "create-note")!;
3941
+ const result = await create.execute({
3942
+ content: "x",
3943
+ tags: ["article_task"],
3944
+ metadata: { status: "todo" }, // valid under task (winner), invalid under publication
3945
+ }) as any;
3946
+
3947
+ const conflict = result.validation_status.warnings.find(
3948
+ (w: any) => w.reason === "schema_conflict",
3949
+ );
3950
+ expect(conflict).toBeDefined();
3951
+ expect(conflict.field).toBe("status");
3952
+ expect(conflict.schema).toBe("task"); // winner
3953
+ // No enum_mismatch — the value is valid under task's enum.
3954
+ const enumMismatch = result.validation_status.warnings.find(
3955
+ (w: any) => w.reason === "enum_mismatch",
3956
+ );
3957
+ expect(enumMismatch).toBeUndefined();
3958
+ });
3959
+
3960
+ it("`_default` universal parent: untagged note picks up `_default`'s schema", async () => {
3961
+ await store.upsertTagRecord("_default", {
3962
+ fields: { author: { type: "string" } },
3963
+ });
3964
+
3965
+ const tools = generateMcpTools(store);
3966
+ const create = tools.find((t) => t.name === "create-note")!;
3967
+ const result = await create.execute({
3968
+ content: "untagged note",
3969
+ metadata: { author: 42 }, // wrong type
3970
+ }) as any;
3971
+
3972
+ expect(result.validation_status.schemas).toEqual(["_default"]);
3973
+ expect(result.validation_status.warnings.length).toBe(1);
3974
+ expect(result.validation_status.warnings[0].field).toBe("author");
3975
+ });
3976
+
3977
+ it("`_default` universal parent: tagged note gets `_default` + its tag schema", async () => {
3978
+ await store.upsertTagRecord("_default", {
3979
+ fields: { author: { type: "string" } },
3980
+ });
3981
+ await store.upsertTagRecord("task", {
3982
+ fields: { priority: { type: "string" } },
3983
+ });
3984
+
3985
+ const tools = generateMcpTools(store);
3986
+ const create = tools.find((t) => t.name === "create-note")!;
3987
+ const result = await create.execute({
3988
+ content: "x",
3989
+ tags: ["task"],
3990
+ metadata: { author: 1, priority: 2 }, // both wrong type
3991
+ }) as any;
3992
+
3993
+ expect(result.validation_status.warnings.length).toBe(2);
3994
+ const schemas = new Set(
3995
+ result.validation_status.warnings.map((w: any) => w.schema),
3996
+ );
3997
+ expect(schemas.has("_default")).toBe(true);
3998
+ expect(schemas.has("task")).toBe(true);
3999
+ });
4000
+
4001
+ it("`_default` query expansion: query-notes { tag: '_default' } returns every note", async () => {
4002
+ await store.upsertTagRecord("_default", { description: "universal parent" });
4003
+ const a = await store.createNote("alpha", { tags: ["task"] });
4004
+ const b = await store.createNote("beta", { tags: ["project"] });
4005
+ const g = await store.createNote("gamma"); // untagged
4006
+
4007
+ const tools = generateMcpTools(store);
4008
+ const query = tools.find((t) => t.name === "query-notes")!;
4009
+ const result = await query.execute({ tag: "_default" }) as any;
4010
+
4011
+ expect(result.length).toBe(3);
4012
+ const ids = (result as { id: string }[]).map((n) => n.id).sort();
4013
+ expect(ids).toEqual([a.id, b.id, g.id].sort());
4014
+ });
4015
+
4016
+ it("missing parent: non-existent name in parent_names is silently skipped", async () => {
4017
+ await store.upsertTagRecord("task", {
4018
+ parent_names: ["nonexistent_tag"],
4019
+ fields: { priority: { type: "string" } },
4020
+ });
4021
+
4022
+ const tools = generateMcpTools(store);
4023
+ const create = tools.find((t) => t.name === "create-note")!;
4024
+ const result = await create.execute({
4025
+ content: "x",
4026
+ tags: ["task"],
4027
+ metadata: { priority: 999 }, // wrong type
4028
+ }) as any;
4029
+
4030
+ // No error. task's own field still validates.
4031
+ expect(result.validation_status.warnings.length).toBe(1);
4032
+ expect(result.validation_status.warnings[0].field).toBe("priority");
4033
+ });
4034
+
4035
+ it("`_default` deleted mid-session: cache invalidates, default behavior goes away", async () => {
4036
+ await store.upsertTagRecord("_default", {
4037
+ fields: { author: { type: "string" } },
4038
+ });
4039
+
4040
+ const tools = generateMcpTools(store);
4041
+ const create = tools.find((t) => t.name === "create-note")!;
4042
+ let result = await create.execute({
4043
+ content: "x",
4044
+ metadata: { author: 42 }, // wrong type
4045
+ }) as any;
4046
+ expect(result.validation_status.warnings.length).toBe(1);
4047
+
4048
+ await store.deleteTag("_default");
4049
+
4050
+ result = await create.execute({
4051
+ content: "y",
4052
+ metadata: { author: 42 },
4053
+ }) as any;
4054
+ expect(result.validation_status).toBeUndefined();
4055
+ });
4056
+
4057
+ it("`_default` + `tagMatch: 'any'` drops the tag filter entirely (every note matches)", async () => {
4058
+ // Folded from PR #272 review (N1). With OR-semantics, `_default` matches
4059
+ // everything → the disjunction collapses regardless of what else is in
4060
+ // the list. Pre-fold this would have narrowed to `task`-tagged notes.
4061
+ await store.upsertTagRecord("_default", { description: "universal parent" });
4062
+ const a = await store.createNote("alpha", { tags: ["task"] });
4063
+ const b = await store.createNote("beta", { tags: ["project"] });
4064
+ const g = await store.createNote("gamma"); // untagged
4065
+
4066
+ const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "any" });
4067
+ const ids = results.map((n) => n.id).sort();
4068
+ expect(ids).toEqual([a.id, b.id, g.id].sort());
4069
+ });
4070
+
4071
+ it("`_default` + `tagMatch: 'all'` drops only `_default` from the AND-set", async () => {
4072
+ // Symmetric guard for N1: AND-semantics should NOT collapse — `_default`
4073
+ // is universally satisfied so it can be dropped, but other tags still
4074
+ // narrow the result set.
4075
+ await store.upsertTagRecord("_default", { description: "universal parent" });
4076
+ const a = await store.createNote("alpha", { tags: ["task"] });
4077
+ await store.createNote("beta", { tags: ["project"] });
4078
+ await store.createNote("gamma"); // untagged
4079
+
4080
+ const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "all" });
4081
+ expect(results.length).toBe(1);
4082
+ expect(results[0].id).toBe(a.id);
4083
+ });
4084
+
4085
+ it("`searchNotes` with `_default` returns matches from every note (including untagged)", async () => {
4086
+ // Folded from PR #272 review (N2). FTS-backed search now short-circuits
4087
+ // the tag filter when `_default` is requested, matching `queryNotes`.
4088
+ await store.upsertTagRecord("_default", { description: "universal parent" });
4089
+ const a = await store.createNote("findme alpha", { tags: ["task"] });
4090
+ const b = await store.createNote("findme beta"); // untagged
4091
+
4092
+ const results = await store.searchNotes("findme", { tags: ["_default"] });
4093
+ const ids = results.map((n) => n.id).sort();
4094
+ expect(ids).toEqual([a.id, b.id].sort());
4095
+ });
4096
+
4097
+ it("`schema_conflict` warning carries structured `loser_schema`", async () => {
4098
+ // Folded from PR #272 review (N3). Agents shouldn't have to regex
4099
+ // `message` to find the overridden tag — surface it structurally.
4100
+ await store.upsertTagRecord("task", {
4101
+ fields: { status: { type: "string", enum: ["todo", "done"] } },
4102
+ });
4103
+ await store.upsertTagRecord("publication", {
4104
+ fields: { status: { type: "string", enum: ["draft", "published"] } },
4105
+ });
4106
+ await store.upsertTagRecord("article_task", {
4107
+ parent_names: ["task", "publication"],
4108
+ });
4109
+
4110
+ const tools = generateMcpTools(store);
4111
+ const create = tools.find((t) => t.name === "create-note")!;
4112
+ const result = await create.execute({
4113
+ content: "x",
4114
+ tags: ["article_task"],
4115
+ metadata: { status: "todo" },
4116
+ }) as any;
4117
+
4118
+ const conflict = result.validation_status.warnings.find(
4119
+ (w: any) => w.reason === "schema_conflict",
4120
+ );
4121
+ expect(conflict).toBeDefined();
4122
+ expect(conflict.schema).toBe("task"); // winner
4123
+ expect(conflict.loser_schema).toBe("publication"); // overridden
4124
+ });
4125
+
4126
+ it("non-conflict warnings (type/enum mismatch) don't carry `loser_schema`", async () => {
4127
+ // Symmetric guard for N3: `loser_schema` is only meaningful for
4128
+ // schema_conflict; absent on type/enum mismatches.
4129
+ await store.upsertTagSchema("task", {
4130
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
4131
+ });
4132
+
4133
+ const tools = generateMcpTools(store);
4134
+ const create = tools.find((t) => t.name === "create-note")!;
4135
+ const result = await create.execute({
4136
+ content: "x",
4137
+ tags: ["task"],
4138
+ metadata: { priority: "ULTRA" },
4139
+ }) as any;
4140
+
4141
+ expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
4142
+ expect(result.validation_status.warnings[0].loser_schema).toBeUndefined();
4143
+ });
4144
+
4145
+ it("invalidates schema cache when only parent_names changes (no fields touched)", async () => {
4146
+ // Regression guard: pre-vault#270, parent_names changes only invalidated
4147
+ // the hierarchy cache. Now they must also invalidate the schema cache,
4148
+ // since inheritance walks parent chains at validation time.
4149
+ await store.upsertTagRecord("base", {
4150
+ fields: { tier: { type: "string" } },
4151
+ });
4152
+ await store.upsertTagRecord("derived", { description: "starts orphaned" });
4153
+
4154
+ const tools = generateMcpTools(store);
4155
+ const create = tools.find((t) => t.name === "create-note")!;
4156
+ let result = await create.execute({
4157
+ content: "x",
4158
+ tags: ["derived"],
4159
+ metadata: { tier: 1 },
4160
+ }) as any;
4161
+ expect(result.validation_status).toBeUndefined();
4162
+
4163
+ // Wire up inheritance — fields *not* touched, only parent_names.
4164
+ await store.upsertTagRecord("derived", { parent_names: ["base"] });
4165
+
4166
+ result = await create.execute({
4167
+ content: "y",
4168
+ tags: ["derived"],
4169
+ metadata: { tier: 1 }, // wrong type per base.tier
4170
+ }) as any;
4171
+ expect(result.validation_status.warnings.length).toBe(1);
4172
+ expect(result.validation_status.warnings[0].field).toBe("tier");
4173
+ expect(result.validation_status.warnings[0].schema).toBe("base");
3508
4174
  });
3509
4175
  });
3510
4176
 
@@ -3904,199 +4570,111 @@ describe("schema migration v13 → v14", async () => {
3904
4570
  });
3905
4571
 
3906
4572
  // ---------------------------------------------------------------------------
3907
- // Schema migration v14v15 — vault#246 (_schemas/* + _schema_defaults retired)
4573
+ // Schema migration v16v17 — vault#267 (note_schemas + schema_mappings rip)
3908
4574
  // ---------------------------------------------------------------------------
3909
4575
 
3910
- describe("schema migration v14v15", async () => {
3911
- // Build a v14-shape DB by:
3912
- // 1. Calling initSchema (so we get the full v15 shape, including
3913
- // note_schemas / schema_mappings tables).
3914
- // 2. Manually wiping the new tables, then writing the legacy
3915
- // `_schemas/<name>` and `_schema_defaults` notes.
3916
- // 3. Re-running initSchema — the migration's short-circuit (empty
3917
- // destination tables) should kick in and copy the data over.
3918
- async function buildV14ShapeWithLegacyNotes(): Promise<Database> {
4576
+ describe("schema migration v16v17", async () => {
4577
+ // Build a v16-shape DB with the standalone note_schemas + schema_mappings
4578
+ // tables and a couple of rows, then run initSchema again. The v17 migration
4579
+ // should drop both tables.
4580
+ async function buildV16ShapeWithLegacyTables(): Promise<Database> {
3919
4581
  const { Database } = await import("bun:sqlite");
3920
- const { initSchema } = await import("./schema.ts");
3921
4582
  const db = new Database(":memory:");
3922
- initSchema(db);
3923
4583
 
3924
- // Write the legacy notes that the v14 vaults stored config in.
3925
- const now = new Date().toISOString();
3926
- const insertNote = db.prepare(
3927
- "INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
3928
- );
3929
- insertNote.run(
3930
- "s1",
3931
- "",
3932
- "_schemas/task",
3933
- JSON.stringify({
3934
- description: "A task",
3935
- fields: { priority: { type: "string", enum: ["high", "low"] } },
3936
- required: ["priority"],
3937
- }),
3938
- now,
3939
- );
3940
- insertNote.run(
3941
- "s2",
3942
- "",
3943
- "_schemas/journal-entry",
3944
- JSON.stringify({ fields: { mood: { type: "string" } } }),
3945
- now,
3946
- );
3947
- insertNote.run(
3948
- "d1",
3949
- "",
3950
- "_schema_defaults",
3951
- JSON.stringify({
3952
- path_prefixes: { "journal/": "journal-entry" },
3953
- tags: { task: "task", "follow-up": "task" },
3954
- }),
3955
- now,
3956
- );
4584
+ // Create the v16-era schema fragments by hand. We can't call the post-v17
4585
+ // initSchema to do this — SCHEMA_SQL no longer creates the dropped
4586
+ // tables. Build them manually here so the migration test exercises the
4587
+ // "upgrading from v16" path.
4588
+ db.exec("PRAGMA journal_mode = WAL");
4589
+ db.exec("PRAGMA foreign_keys = ON");
4590
+ db.exec(`CREATE TABLE note_schemas (
4591
+ name TEXT PRIMARY KEY,
4592
+ description TEXT,
4593
+ fields TEXT,
4594
+ required TEXT,
4595
+ created_at TEXT,
4596
+ updated_at TEXT
4597
+ )`);
4598
+ db.exec(`CREATE TABLE schema_mappings (
4599
+ schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
4600
+ match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
4601
+ match_value TEXT NOT NULL,
4602
+ PRIMARY KEY (schema_name, match_kind, match_value)
4603
+ )`);
4604
+ db.exec("CREATE INDEX idx_schema_mappings_match ON schema_mappings(match_kind, match_value)");
3957
4605
 
3958
- // Wipe the destination tables so the migration short-circuit doesn't
3959
- // fire (otherwise initSchema treats them as already migrated).
3960
- db.exec("DELETE FROM schema_mappings");
3961
- db.exec("DELETE FROM note_schemas");
4606
+ const now = new Date().toISOString();
4607
+ db.prepare(
4608
+ "INSERT INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
4609
+ ).run("task", "A task", '{"priority":{"type":"string"}}', '["priority"]', now, now);
4610
+ db.prepare(
4611
+ "INSERT INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
4612
+ ).run("journal-entry", now, now);
4613
+ db.prepare(
4614
+ "INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
4615
+ ).run("task", "tag", "task");
4616
+ db.prepare(
4617
+ "INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
4618
+ ).run("journal-entry", "path_prefix", "journal/");
3962
4619
 
3963
4620
  return db;
3964
4621
  }
3965
4622
 
3966
- it("copies `_schemas/<name>` notes → note_schemas with description / fields / required", async () => {
3967
- const db = await buildV14ShapeWithLegacyNotes();
4623
+ it("drops note_schemas + schema_mappings tables on upgrade", async () => {
4624
+ const db = await buildV16ShapeWithLegacyTables();
3968
4625
  const { initSchema } = await import("./schema.ts");
3969
4626
  initSchema(db);
3970
4627
 
3971
- const taskRow = db.prepare(
3972
- "SELECT description, fields, required FROM note_schemas WHERE name = 'task'",
3973
- ).get() as any;
3974
- expect(taskRow.description).toBe("A task");
3975
- expect(JSON.parse(taskRow.fields).priority.enum).toEqual(["high", "low"]);
3976
- expect(JSON.parse(taskRow.required)).toEqual(["priority"]);
3977
-
3978
- const journalRow = db.prepare(
3979
- "SELECT description, fields FROM note_schemas WHERE name = 'journal-entry'",
3980
- ).get() as any;
3981
- expect(journalRow.description).toBeNull();
3982
- expect(JSON.parse(journalRow.fields).mood.type).toBe("string");
4628
+ const tables = db.prepare(
4629
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4630
+ ).all() as { name: string }[];
4631
+ expect(tables).toEqual([]);
3983
4632
 
3984
4633
  db.close();
3985
4634
  });
3986
4635
 
3987
- it("copies `_schema_defaults` schema_mappings (path_prefixes + tags)", async () => {
3988
- const db = await buildV14ShapeWithLegacyNotes();
3989
- const { initSchema } = await import("./schema.ts");
3990
- initSchema(db);
3991
-
3992
- const mappings = db.prepare(
3993
- "SELECT schema_name, match_kind, match_value FROM schema_mappings ORDER BY match_kind, match_value",
3994
- ).all() as { schema_name: string; match_kind: string; match_value: string }[];
3995
- expect(mappings).toEqual([
3996
- { schema_name: "journal-entry", match_kind: "path_prefix", match_value: "journal/" },
3997
- { schema_name: "task", match_kind: "tag", match_value: "follow-up" },
3998
- { schema_name: "task", match_kind: "tag", match_value: "task" },
3999
- ]);
4000
-
4001
- db.close();
4002
- });
4003
-
4004
- it("auto-creates a stub schema row when a mapping references an undeclared schema (FK)", async () => {
4636
+ it("idempotent running on an already-v17 vault is a no-op", async () => {
4005
4637
  const { Database } = await import("bun:sqlite");
4006
4638
  const { initSchema } = await import("./schema.ts");
4007
4639
  const db = new Database(":memory:");
4008
- initSchema(db);
4009
-
4010
- // Only a defaults note — no `_schemas/orphan` definition.
4011
- db.prepare(
4012
- "INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
4013
- ).run(
4014
- "d",
4015
- "",
4016
- "_schema_defaults",
4017
- JSON.stringify({ tags: { orphan: "orphan" } }),
4018
- new Date().toISOString(),
4019
- );
4020
- db.exec("DELETE FROM schema_mappings");
4021
- db.exec("DELETE FROM note_schemas");
4022
-
4023
- initSchema(db);
4024
-
4025
- // Stub schema row was created so the FK on schema_mappings holds.
4026
- const stub = db.prepare("SELECT name FROM note_schemas WHERE name = 'orphan'").get();
4027
- expect(stub).toBeTruthy();
4028
- const mapping = db.prepare(
4029
- "SELECT match_kind, match_value FROM schema_mappings WHERE schema_name = 'orphan'",
4030
- ).get() as any;
4031
- expect(mapping).toEqual({ match_kind: "tag", match_value: "orphan" });
4640
+ initSchema(db); // First run: fresh v17 shape.
4032
4641
 
4033
- db.close();
4034
- });
4642
+ // Sanity: the dropped tables don't exist on a fresh vault.
4643
+ let tables = db.prepare(
4644
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4645
+ ).all() as { name: string }[];
4646
+ expect(tables).toEqual([]);
4035
4647
 
4036
- it("legacy `_schemas/*` and `_schema_defaults` notes are left in place (audit trail)", async () => {
4037
- const db = await buildV14ShapeWithLegacyNotes();
4038
- const { initSchema } = await import("./schema.ts");
4648
+ // Second run shouldn't crash and the tables stay absent.
4039
4649
  initSchema(db);
4040
-
4041
- const schemaNote = db.prepare("SELECT id FROM notes WHERE path = '_schemas/task'").get();
4042
- expect(schemaNote).toBeTruthy();
4043
- const defaultsNote = db.prepare("SELECT id FROM notes WHERE path = '_schema_defaults'").get();
4044
- expect(defaultsNote).toBeTruthy();
4650
+ tables = db.prepare(
4651
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
4652
+ ).all() as { name: string }[];
4653
+ expect(tables).toEqual([]);
4045
4654
 
4046
4655
  db.close();
4047
4656
  });
4048
4657
 
4049
- it("is idempotent second initSchema doesn't re-copy or duplicate rows", async () => {
4050
- const db = await buildV14ShapeWithLegacyNotes();
4051
- const { initSchema } = await import("./schema.ts");
4052
- initSchema(db);
4053
-
4054
- const schemaCount1 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
4055
- const mappingCount1 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
4056
-
4057
- initSchema(db);
4058
-
4059
- const schemaCount2 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
4060
- const mappingCount2 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
4061
- expect(schemaCount2).toBe(schemaCount1);
4062
- expect(mappingCount2).toBe(mappingCount1);
4658
+ it("preserves notes + tags + tokens across the rip", async () => {
4659
+ const db = await buildV16ShapeWithLegacyTables();
4063
4660
 
4064
- db.close();
4065
- });
4066
-
4067
- it("no legacy notes → migration is a no-op (empty tables)", async () => {
4068
- const { Database } = await import("bun:sqlite");
4661
+ // Bring the rest of the schema up to v16 baseline so notes/tags/tokens
4662
+ // exist, then re-run initSchema (which finishes the v17 migration).
4069
4663
  const { initSchema } = await import("./schema.ts");
4070
- const db = new Database(":memory:");
4071
4664
  initSchema(db);
4072
4665
 
4073
- expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBe(0);
4074
- expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
4075
-
4076
- db.close();
4077
- });
4078
-
4079
- // Short-circuit uses `||` not `&&`: a vault with schemas-but-no-mappings
4080
- // is a valid post-v15 state. Legacy `_schemas/*` notes left around from
4081
- // a prior import shouldn't get re-folded on every boot. (The mirror case —
4082
- // mappings-but-no-schemas — is structurally impossible because the
4083
- // schema_mappings FK to note_schemas has ON DELETE CASCADE.)
4084
- it("doesn't re-run when only one destination table is non-empty", async () => {
4085
- const db = await buildV14ShapeWithLegacyNotes();
4086
- const { initSchema } = await import("./schema.ts");
4666
+ // Populate some unrelated state and re-run; nothing else should move.
4667
+ await import("./store.ts").then(async ({ SqliteStore }) => {
4668
+ const s = new SqliteStore(db);
4669
+ await s.createNote("hello", { id: "n1", tags: ["task"] });
4670
+ await s.upsertTagRecord("task", { description: "still here" });
4671
+ });
4087
4672
  initSchema(db);
4088
4673
 
4089
- // After first run: tables populated, legacy notes still in `notes`.
4090
- expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBeGreaterThan(0);
4091
- expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBeGreaterThan(0);
4092
- expect((db.prepare("SELECT COUNT(*) as c FROM notes WHERE path GLOB '_schemas/*'").get() as any).c).toBeGreaterThan(0);
4093
-
4094
- // Wipe mappings only; schemas remain non-empty. With the buggy `&&`
4095
- // short-circuit, the migration would re-scan `_schema_defaults` and
4096
- // rebuild the mappings table. With `||` it correctly no-ops.
4097
- db.exec("DELETE FROM schema_mappings");
4098
- initSchema(db);
4099
- expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
4674
+ const note = db.prepare("SELECT id, content FROM notes WHERE id = ?").get("n1") as any;
4675
+ expect(note?.content).toBe("hello");
4676
+ const tag = db.prepare("SELECT description FROM tags WHERE name = ?").get("task") as any;
4677
+ expect(tag?.description).toBe("still here");
4100
4678
 
4101
4679
  db.close();
4102
4680
  });
@@ -4223,3 +4801,194 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
4223
4801
  expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
4224
4802
  });
4225
4803
  });
4804
+
4805
+ // ---- Vault projection (vault#271) ----
4806
+
4807
+ describe("vault projection (vault#271)", async () => {
4808
+ it("projects tags-with-schemas with effective inheritance", async () => {
4809
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4810
+
4811
+ // Universal `_default` parent declares `created_by`.
4812
+ await store.upsertTagRecord("_default", {
4813
+ fields: { created_by: { type: "string", description: "Origin agent" } },
4814
+ });
4815
+ // `person` declares `email` and inherits `created_by`.
4816
+ await store.upsertTagRecord("person", {
4817
+ description: "A person",
4818
+ fields: { email: { type: "string", indexed: true } },
4819
+ });
4820
+ // `employee` extends `person` — should inherit BOTH `email` and `created_by`.
4821
+ await store.upsertTagRecord("employee", {
4822
+ description: "Person who works here",
4823
+ fields: { title: { type: "string" } },
4824
+ parent_names: ["person"],
4825
+ });
4826
+
4827
+ const projection = buildVaultProjection(db);
4828
+
4829
+ const byName = Object.fromEntries(projection.tags.map((t) => [t.name, t]));
4830
+
4831
+ // _default appears (has fields).
4832
+ expect(byName._default).toBeTruthy();
4833
+ expect(byName._default.parents).toEqual([]);
4834
+ expect(byName._default.effective_parents).toEqual([]);
4835
+
4836
+ // person inherits _default's universal field.
4837
+ expect(byName.person.parents).toEqual([]);
4838
+ expect(byName.person.effective_parents).toEqual(["_default"]);
4839
+ expect(Object.keys(byName.person.effective_fields).sort()).toEqual([
4840
+ "created_by",
4841
+ "email",
4842
+ ]);
4843
+ // own fields stay separate
4844
+ expect(Object.keys(byName.person.fields ?? {})).toEqual(["email"]);
4845
+
4846
+ // employee walks person → _default.
4847
+ expect(byName.employee.parents).toEqual(["person"]);
4848
+ expect(byName.employee.effective_parents).toEqual(["person", "_default"]);
4849
+ expect(Object.keys(byName.employee.effective_fields).sort()).toEqual([
4850
+ "created_by",
4851
+ "email",
4852
+ "title",
4853
+ ]);
4854
+ });
4855
+
4856
+ it("catalogs indexed fields across declarers", async () => {
4857
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4858
+
4859
+ // Indexed-field lifecycle is owned by the update-tag MCP tool, not
4860
+ // store.upsertTagRecord — go through the tool so the indexed_fields
4861
+ // table actually gets populated.
4862
+ const tools = generateMcpTools(store);
4863
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4864
+ await updateTag.execute({
4865
+ tag: "task",
4866
+ fields: { status: { type: "string", indexed: true } },
4867
+ });
4868
+ await updateTag.execute({
4869
+ tag: "project",
4870
+ fields: {
4871
+ status: { type: "string", indexed: true },
4872
+ priority: { type: "integer", indexed: true },
4873
+ },
4874
+ });
4875
+
4876
+ const projection = buildVaultProjection(db);
4877
+ const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
4878
+
4879
+ expect(byName.status).toBeTruthy();
4880
+ expect(byName.status.type).toBe("string");
4881
+ expect(byName.status.tags.sort()).toEqual(["project", "task"]);
4882
+
4883
+ expect(byName.priority).toBeTruthy();
4884
+ expect(byName.priority.type).toBe("integer");
4885
+ expect(byName.priority.tags).toEqual(["project"]);
4886
+ });
4887
+
4888
+ it("includes the static query-hint catalog", async () => {
4889
+ const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
4890
+ const projection = buildVaultProjection(db);
4891
+ expect(projection.query_hints.length).toBe(QUERY_HINTS.length);
4892
+ expect(projection.query_hints.some((h) => h.startsWith("query-notes { tag:"))).toBe(true);
4893
+ expect(projection.query_hints.some((h) => h.includes("near:"))).toBe(true);
4894
+ });
4895
+
4896
+ it("includes stats only when requested", async () => {
4897
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4898
+ await store.createNote("a", { tags: ["x"] });
4899
+ await store.createNote("b", { tags: ["x", "y"] });
4900
+
4901
+ const without = buildVaultProjection(db);
4902
+ expect(without.stats).toBeUndefined();
4903
+
4904
+ const withStats = buildVaultProjection(db, { includeStats: true });
4905
+ expect(withStats.stats).toBeTruthy();
4906
+ expect(withStats.stats!.totalNotes).toBe(2);
4907
+ expect(withStats.stats!.tagCount).toBe(2);
4908
+ });
4909
+
4910
+ it("degrades gracefully on an empty vault", async () => {
4911
+ const { buildVaultProjection } = await import("./vault-projection.ts");
4912
+ const projection = buildVaultProjection(db);
4913
+ expect(projection.tags).toEqual([]);
4914
+ expect(projection.indexed_fields).toEqual([]);
4915
+ // Query hints are static — present even on a blank vault.
4916
+ expect(projection.query_hints.length).toBeGreaterThan(0);
4917
+ });
4918
+
4919
+ it("renders a markdown brief listing tags-with-schemas and indexed fields", async () => {
4920
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4921
+ "./vault-projection.ts"
4922
+ );
4923
+
4924
+ await store.createNote("a", { tags: ["person"] });
4925
+ const tools = generateMcpTools(store);
4926
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4927
+ await updateTag.execute({
4928
+ tag: "person",
4929
+ description: "A person",
4930
+ fields: { email: { type: "string", indexed: true } },
4931
+ });
4932
+
4933
+ const projection = buildVaultProjection(db, { includeStats: true });
4934
+ const md = projectionToMarkdown({
4935
+ vaultName: "test",
4936
+ description: "My vault",
4937
+ projection,
4938
+ });
4939
+
4940
+ expect(md).toContain('You are connected to Parachute Vault "test"');
4941
+ expect(md).toContain("My vault");
4942
+ expect(md).toContain("1 tag with schemas: person");
4943
+ expect(md).toContain("Indexed metadata fields");
4944
+ expect(md).toContain("email");
4945
+ expect(md).toContain("#person");
4946
+ expect(md).toContain("vault-info");
4947
+ expect(md).toContain("list-tags { include_schema: true }");
4948
+ });
4949
+
4950
+ it("markdown brief degrades gracefully when no schemas declared", async () => {
4951
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4952
+ "./vault-projection.ts"
4953
+ );
4954
+
4955
+ const projection = buildVaultProjection(db, { includeStats: true });
4956
+ const md = projectionToMarkdown({
4957
+ vaultName: "fresh",
4958
+ description: null,
4959
+ projection,
4960
+ });
4961
+
4962
+ expect(md).toContain('Parachute Vault "fresh"');
4963
+ expect(md).toContain("No tag schemas declared");
4964
+ expect(md).toContain("No indexed metadata fields");
4965
+ expect(md).toContain("Querying");
4966
+ });
4967
+
4968
+ it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
4969
+ const { buildVaultProjection, projectionToMarkdown } = await import(
4970
+ "./vault-projection.ts"
4971
+ );
4972
+
4973
+ for (let i = 0; i < 50; i++) {
4974
+ await store.upsertTagRecord(`schema_tag_${i}`, {
4975
+ description: `Description for tag ${i} — covers what this tag is used for in the vault.`,
4976
+ fields: {
4977
+ [`field_${i}_a`]: { type: "string", indexed: i % 3 === 0 },
4978
+ [`field_${i}_b`]: { type: "integer" },
4979
+ },
4980
+ });
4981
+ }
4982
+
4983
+ const projection = buildVaultProjection(db, { includeStats: true });
4984
+ const md = projectionToMarkdown({
4985
+ vaultName: "big",
4986
+ description: "Big test vault",
4987
+ projection,
4988
+ });
4989
+
4990
+ // Rough token approximation: 1 token ≈ 4 chars. Budget: 5K tokens.
4991
+ const approxTokens = md.length / 4;
4992
+ expect(approxTokens).toBeLessThan(5000);
4993
+ });
4994
+ });