@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. package/web/ui/dist/index.html +1 -1
package/src/vault.test.ts CHANGED
@@ -539,6 +539,37 @@ describe("MCP tools", async () => {
539
539
  expect(result.tags).toContain("daily");
540
540
  });
541
541
 
542
+ // FIX 2 (vault#589) — the MCP door rejects illegal paths too. The core tool
543
+ // throws a `PathValidationError` (error_type invalid_path) exactly like
544
+ // ExtensionValidationError; mcp-http.ts's generic error_type mapping turns it
545
+ // into a structured domain error at the transport. Assert the MCP tool path
546
+ // itself refuses the write (parity with the REST-door tests below).
547
+ test("create-note MCP tool rejects a '..' path with error_type invalid_path", async () => {
548
+ const tools = generateMcpTools(store);
549
+ const createNote = tools.find((t) => t.name === "create-note")!;
550
+ let thrown: any;
551
+ try {
552
+ await createNote.execute({ content: "x", path: "../escape" });
553
+ } catch (e) { thrown = e; }
554
+ expect(thrown).toBeTruthy();
555
+ expect(thrown.error_type).toBe("invalid_path");
556
+ expect(thrown.code).toBe("INVALID_PATH");
557
+ // Nothing written.
558
+ expect(await store.getNoteByPath("escape")).toBeNull();
559
+ });
560
+
561
+ test("create-note MCP tool rejects a NUL path with error_type invalid_path", async () => {
562
+ const NUL = String.fromCharCode(0);
563
+ const tools = generateMcpTools(store);
564
+ const createNote = tools.find((t) => t.name === "create-note")!;
565
+ let thrown: any;
566
+ try {
567
+ await createNote.execute({ content: "x", path: `bad${NUL}path` });
568
+ } catch (e) { thrown = e; }
569
+ expect(thrown).toBeTruthy();
570
+ expect(thrown.error_type).toBe("invalid_path");
571
+ });
572
+
542
573
  test("every tool has inputSchema and execute", () => {
543
574
  const tools = generateMcpTools(store);
544
575
  for (const tool of tools) {
@@ -675,6 +706,34 @@ describe("scoped MCP wrapper", async () => {
675
706
  closeAllStores();
676
707
  });
677
708
 
709
+ test("vault-info includes a compact structural map WITHOUT include_stats (front-door)", async () => {
710
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
711
+ const { writeVaultConfig } = await import("./config.ts");
712
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
713
+
714
+ const vaultName = `map-${Date.now()}`;
715
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
716
+
717
+ const vaultStore = getVaultStore(vaultName);
718
+ await vaultStore.createNote("a", { tags: ["person"], path: "People/Alice" });
719
+ await vaultStore.createNote("b", { tags: ["person"] }); // no path
720
+
721
+ const tools = generateScopedMcpTools(vaultName);
722
+ const vaultInfo = tools.find((t) => t.name === "vault-info")!;
723
+
724
+ // No `include_stats` flag — the map must still be present (that's the
725
+ // whole point: orient in ONE call, no flag needed).
726
+ const result = await vaultInfo.execute({}) as any;
727
+ expect(result.stats).toBeUndefined();
728
+ expect(result.map).toBeTruthy();
729
+ expect(result.map.total_notes).toBe(2);
730
+ expect(result.map.tags).toEqual([{ name: "person", count: 2 }]);
731
+ expect(result.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
732
+ expect(result.map.unfiled_notes).toBe(1);
733
+
734
+ closeAllStores();
735
+ });
736
+
678
737
  test("getServerInstruction renders projection markdown for a populated vault (vault#271)", async () => {
679
738
  const { getServerInstruction } = await import("./mcp-tools.ts");
680
739
  const { writeVaultConfig } = await import("./config.ts");
@@ -1267,6 +1326,51 @@ describe("scoped MCP wrapper", async () => {
1267
1326
  closeAllStores();
1268
1327
  });
1269
1328
 
1329
+ test("scoped vault-info's map covers only notes reachable through an in-scope tag (front-door)", async () => {
1330
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1331
+ const { writeVaultConfig } = await import("./config.ts");
1332
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1333
+
1334
+ const vaultName = `tagscope-vault-info-map-${Date.now()}`;
1335
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1336
+
1337
+ const store0 = getVaultStore(vaultName);
1338
+ await store0.createNote("a", { tags: ["work"], path: "Work/One" });
1339
+ await store0.createNote("b", { tags: ["work"] }); // no path
1340
+ await store0.createNote("c", { tags: ["personal"], path: "Personal/Two" });
1341
+
1342
+ // Scoped to `work` only.
1343
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1344
+ const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
1345
+
1346
+ expect(result.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
1347
+ expect(result.map.tags).toEqual([{ name: "work", count: 2 }]);
1348
+ expect(result.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
1349
+ expect(result.map.unfiled_notes).toBe(1);
1350
+
1351
+ closeAllStores();
1352
+ });
1353
+
1354
+ test("scoped vault-info with an allowlist matching nothing returns an all-zero map, not the full vault", async () => {
1355
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1356
+ const { writeVaultConfig } = await import("./config.ts");
1357
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1358
+
1359
+ const vaultName = `tagscope-vault-info-map-empty-${Date.now()}`;
1360
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1361
+
1362
+ const store0 = getVaultStore(vaultName);
1363
+ await store0.createNote("a", { tags: ["work"], path: "Work/One" });
1364
+
1365
+ // Scoped to a tag that doesn't exist in this vault at all.
1366
+ const tools = generateScopedMcpTools(vaultName, authForTags(["nonexistent"]) as any);
1367
+ const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
1368
+
1369
+ expect(result.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
1370
+
1371
+ closeAllStores();
1372
+ });
1373
+
1270
1374
  test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
1271
1375
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1272
1376
  const { writeVaultConfig } = await import("./config.ts");
@@ -2333,6 +2437,37 @@ describe("HTTP /notes", async () => {
2333
2437
  expect(body.content).toBe("hello");
2334
2438
  });
2335
2439
 
2440
+ // ---- Title-fallback resolution (additive — id/path/basename still win first) ----
2441
+
2442
+ test("GET /notes/:idOrPath resolves via H1 title when id and path both miss", async () => {
2443
+ await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
2444
+ const enc = encodeURIComponent("My Great Note");
2445
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2446
+ expect(res.status).toBe(200);
2447
+ const body = await res.json() as any;
2448
+ expect(body.path).toBe("Inbox/2026-07-10-xyz");
2449
+ });
2450
+
2451
+ test("GET /notes/:idOrPath exact path still wins over a same-named title on another note", async () => {
2452
+ const byPath = await store.createNote("Path note", { path: "My Great Note" });
2453
+ await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
2454
+ const enc = encodeURIComponent("My Great Note");
2455
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2456
+ const body = await res.json() as any;
2457
+ expect(body.id).toBe(byPath.id);
2458
+ expect(body.content).toBe("Path note");
2459
+ });
2460
+
2461
+ test("GET /notes/:idOrPath stays 404 when 2+ notes share the same H1 title", async () => {
2462
+ await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
2463
+ await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
2464
+ const enc = encodeURIComponent("Dup Note");
2465
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2466
+ expect(res.status).toBe(404);
2467
+ const body = await res.json() as any;
2468
+ expect(body.error_type).toBe("not_found");
2469
+ });
2470
+
2336
2471
  test("GET /notes/:id?include_content=false returns lean shape", async () => {
2337
2472
  await store.createNote("hello", { id: "x" });
2338
2473
  const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
@@ -2560,6 +2695,63 @@ describe("HTTP /notes", async () => {
2560
2695
  expect(body.error_type).toBe("invalid_extension");
2561
2696
  });
2562
2697
 
2698
+ // FIX 2 (vault#589) — a note path with a NUL byte or a `..` segment is
2699
+ // rejected at the write surface (400 invalid_path), never persisted. A
2700
+ // NUL-in-path note otherwise slips the export traversal guard and then
2701
+ // aborts the entire vault export; a `..` note is silently un-round-trippable.
2702
+ test("POST /notes rejects a NUL-byte path with 400 invalid_path (not 201)", async () => {
2703
+ const NUL = String.fromCharCode(0);
2704
+ const res = await handleNotes(
2705
+ mkReq("POST", "/notes", { content: "x", path: `bad${NUL}path` }),
2706
+ store,
2707
+ "",
2708
+ );
2709
+ expect(res.status).toBe(400);
2710
+ const body = await res.json() as any;
2711
+ expect(body.error_type).toBe("invalid_path");
2712
+ // Nothing was written.
2713
+ expect(await store.getNoteByPath("bad")).toBeNull();
2714
+ });
2715
+
2716
+ test("POST /notes rejects a '..' path with 400 invalid_path", async () => {
2717
+ const res = await handleNotes(
2718
+ mkReq("POST", "/notes", { content: "x", path: "../escape" }),
2719
+ store,
2720
+ "",
2721
+ );
2722
+ expect(res.status).toBe(400);
2723
+ const body = await res.json() as any;
2724
+ expect(body.error_type).toBe("invalid_path");
2725
+ });
2726
+
2727
+ test("POST /notes still accepts a legitimate path with dots (regression)", async () => {
2728
+ const res = await handleNotes(
2729
+ mkReq("POST", "/notes", { content: "ok", path: "Projects/v1.2/notes" }),
2730
+ store,
2731
+ "",
2732
+ );
2733
+ expect(res.status).toBeLessThan(400);
2734
+ const body = await res.json() as any;
2735
+ expect(body.path).toBe("Projects/v1.2/notes");
2736
+ });
2737
+
2738
+ test("PATCH /notes/:id rejects a '..' path with 400 invalid_path", async () => {
2739
+ const note = await store.createNote("hi", { id: "path-bad", path: "p" });
2740
+ const res = await handleNotes(
2741
+ mkReq("PATCH", "/notes/path-bad", {
2742
+ path: "../../etc/passwd",
2743
+ if_updated_at: note.updatedAt,
2744
+ }),
2745
+ store,
2746
+ "/path-bad",
2747
+ );
2748
+ expect(res.status).toBe(400);
2749
+ const body = await res.json() as any;
2750
+ expect(body.error_type).toBe("invalid_path");
2751
+ // The note's original path is untouched.
2752
+ expect((await store.getNote("path-bad"))!.path).toBe("p");
2753
+ });
2754
+
2563
2755
  test("GET /notes?extension=csv filters by extension", async () => {
2564
2756
  await store.createNote("md note", { path: "a" });
2565
2757
  await store.createNote("csv note", { path: "b", extension: "csv" });
@@ -4672,6 +4864,104 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4672
4864
  expect(links[0]!.relationship).toBe("knows");
4673
4865
  });
4674
4866
 
4867
+ // vault#570 — content-parsed [[wikilinks]] to a missing target used to
4868
+ // fire NO write-time warning over REST either, mirroring the MCP fix.
4869
+ test("POST /notes with a content [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4870
+ const res = await handleNotes(
4871
+ mkReq("POST", "/notes", { content: "See [[Nowhere REST]] for details." }),
4872
+ store,
4873
+ "",
4874
+ );
4875
+ expect(res.status).toBe(201);
4876
+ const body = await res.json() as any;
4877
+ expect(body.warnings).toBeDefined();
4878
+ expect(body.warnings[0].code).toBe("unresolved_link");
4879
+ expect(body.warnings[0].target).toBe("Nowhere REST");
4880
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4881
+ });
4882
+
4883
+ // vault#570 — a target matching ≥2 notes is a distinct situation from a
4884
+ // genuine miss: `ambiguous_link`, not `unresolved_link`, and no edge.
4885
+ test("POST /notes with a content [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
4886
+ await store.createNote("A", { path: "Folder1/RestDup" });
4887
+ await store.createNote("B", { path: "Folder2/RestDup" });
4888
+ const res = await handleNotes(
4889
+ mkReq("POST", "/notes", { content: "See [[RestDup]] for details." }),
4890
+ store,
4891
+ "",
4892
+ );
4893
+ expect(res.status).toBe(201);
4894
+ const body = await res.json() as any;
4895
+ expect(body.warnings).toBeDefined();
4896
+ expect(body.warnings[0].code).toBe("ambiguous_link");
4897
+ expect(body.warnings[0].target).toBe("RestDup");
4898
+ expect(body.warnings[0].candidate_count).toBe(2);
4899
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4900
+ });
4901
+
4902
+ // vault#570 — a structured `links` entry against an ambiguous target gets
4903
+ // the same treatment over REST (shared core implementation).
4904
+ test("POST /notes with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
4905
+ await store.createNote("A", { path: "Folder1/RestDup2" });
4906
+ await store.createNote("B", { path: "Folder2/RestDup2" });
4907
+ const res = await handleNotes(
4908
+ mkReq("POST", "/notes", {
4909
+ content: "no wikilinks here",
4910
+ links: [{ target: "RestDup2", relationship: "knows" }],
4911
+ }),
4912
+ store,
4913
+ "",
4914
+ );
4915
+ expect(res.status).toBe(201);
4916
+ const body = await res.json() as any;
4917
+ expect(body.warnings).toBeDefined();
4918
+ expect(body.warnings[0].code).toBe("ambiguous_link");
4919
+ expect(body.warnings[0].candidate_count).toBe(2);
4920
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4921
+ });
4922
+
4923
+ test("PATCH content update with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4924
+ await store.createNote("plain", { id: "patchable", path: "PatchableRest" });
4925
+ const res = await handleNotes(
4926
+ mkReq("PATCH", "/notes/patchable", { content: "now references [[Not Yet Real REST]]", force: true }),
4927
+ store,
4928
+ "/patchable",
4929
+ );
4930
+ expect(res.status).toBe(200);
4931
+ const body = await res.json() as any;
4932
+ expect(body.warnings).toBeDefined();
4933
+ expect(body.warnings[0].code).toBe("unresolved_link");
4934
+ expect(body.warnings[0].target).toBe("Not Yet Real REST");
4935
+
4936
+ // A tags-only follow-up PATCH must not re-surface the warning.
4937
+ const tagOnly = await handleNotes(
4938
+ mkReq("PATCH", "/notes/patchable", { tags: { add: ["x"] }, force: true }),
4939
+ store,
4940
+ "/patchable",
4941
+ );
4942
+ const tagOnlyBody = await tagOnly.json() as any;
4943
+ expect(tagOnlyBody.warnings).toBeUndefined();
4944
+ });
4945
+
4946
+ // vault#570 — `if_missing: "create"` is a distinct create-shaped code
4947
+ // path in routes.ts (separate from POST /notes); it needs the same fix.
4948
+ test("PATCH if_missing:create with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4949
+ const res = await handleNotes(
4950
+ mkReq("PATCH", "/notes/brand-new-rest", {
4951
+ if_missing: "create",
4952
+ content: "See [[Nowhere Upsert REST]].",
4953
+ }),
4954
+ store,
4955
+ "/brand-new-rest",
4956
+ );
4957
+ expect(res.status).toBe(200);
4958
+ const body = await res.json() as any;
4959
+ expect(body.created).toBe(true);
4960
+ expect(body.warnings).toBeDefined();
4961
+ expect(body.warnings[0].code).toBe("unresolved_link");
4962
+ expect(body.warnings[0].target).toBe("Nowhere Upsert REST");
4963
+ });
4964
+
4675
4965
  test("PATCH without a link mutation or flag does NOT include links", async () => {
4676
4966
  await store.createNote("a", { id: "a" });
4677
4967
  await store.createNote("b", { id: "b" });
@@ -6161,19 +6451,24 @@ describe("stateless MCP transport", async () => {
6161
6451
  expect(toolNames).toContain("list-tags");
6162
6452
  expect(toolNames).toContain("find-path");
6163
6453
  expect(toolNames).toContain("vault-info");
6454
+ // `doctor` moved admin → read (re-tier): a read-only, tag-scope-
6455
+ // restricted diagnostic, visible to a plain vault:read session.
6456
+ expect(toolNames).toContain("doctor");
6164
6457
  // Mutation tools are hidden — filter applied before advertising
6165
6458
  expect(toolNames).not.toContain("create-note");
6166
6459
  expect(toolNames).not.toContain("update-note");
6167
6460
  expect(toolNames).not.toContain("delete-note");
6461
+ // Tag-schema/taxonomy tools moved write → admin (re-tier): hidden from
6462
+ // a vault:read (and, per the next describe block, a plain vault:write)
6463
+ // session — they now need vault:admin.
6168
6464
  expect(toolNames).not.toContain("update-tag");
6169
6465
  expect(toolNames).not.toContain("delete-tag");
6170
6466
  expect(toolNames).not.toContain("rename-tag");
6171
6467
  expect(toolNames).not.toContain("merge-tags");
6172
6468
  // Admin tools (vault#376) are hidden too
6173
6469
  expect(toolNames).not.toContain("manage-token");
6174
- expect(toolNames).not.toContain("doctor");
6175
- // Read tier is exactly 4 tools.
6176
- expect(toolNames.length).toBe(4);
6470
+ // Read tier is exactly 5 tools (doctor added by the re-tier).
6471
+ expect(toolNames.length).toBe(5);
6177
6472
 
6178
6473
  closeAllStores();
6179
6474
  });
@@ -6221,7 +6516,7 @@ describe("stateless MCP transport", async () => {
6221
6516
  // the required scope — the inner guard fired even though the outer tool
6222
6517
  // gate allowed read-only callers through for stats.
6223
6518
  expect(body.result.isError).toBe(true);
6224
- expect(body.result.content[0].text).toContain("vault:write");
6519
+ expect(body.result.content[0].text).toContain("vault:admin");
6225
6520
 
6226
6521
  // And critically: the vault description must NOT have been mutated.
6227
6522
  const cfg = readVaultConfig(vaultName);
@@ -6230,7 +6525,7 @@ describe("stateless MCP transport", async () => {
6230
6525
  closeAllStores();
6231
6526
  });
6232
6527
 
6233
- test("tools/call of vault-info with description arg and vault:write scope is allowed", async () => {
6528
+ test("tools/call of vault-info with description arg and vault:write scope is refused (re-tier: description-write now needs admin)", async () => {
6234
6529
  const { handleScopedMcp } = await import("./mcp-http.ts");
6235
6530
  const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6236
6531
  const { closeAllStores } = await import("./vault-store.ts");
@@ -6267,10 +6562,58 @@ describe("stateless MCP transport", async () => {
6267
6562
  scoped_tags: null,
6268
6563
  });
6269
6564
 
6565
+ expect(res.status).toBe(200);
6566
+ const body = await res.json() as any;
6567
+ // Was `isError: false` pre-re-tier — a plain vault:write session could
6568
+ // update the vault's own description. Now needs vault:admin.
6569
+ expect(body.result.isError).toBe(true);
6570
+ expect(body.result.content[0].text).toContain("vault:admin");
6571
+ expect(readVaultConfig(vaultName)?.description).toBe("original");
6572
+
6573
+ closeAllStores();
6574
+ });
6575
+
6576
+ test("tools/call of vault-info with description arg and vault:admin scope is allowed", async () => {
6577
+ const { handleScopedMcp } = await import("./mcp-http.ts");
6578
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6579
+ const { closeAllStores } = await import("./vault-store.ts");
6580
+
6581
+ const vaultName = `scope-vault-info-admin-${Date.now()}`;
6582
+ writeVaultConfig({
6583
+ name: vaultName,
6584
+ api_keys: [],
6585
+ created_at: new Date().toISOString(),
6586
+ description: "original",
6587
+ });
6588
+
6589
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
6590
+ method: "POST",
6591
+ headers: {
6592
+ "content-type": "application/json",
6593
+ "accept": "application/json, text/event-stream",
6594
+ },
6595
+ body: JSON.stringify({
6596
+ jsonrpc: "2.0",
6597
+ id: 1,
6598
+ method: "tools/call",
6599
+ params: {
6600
+ name: "vault-info",
6601
+ arguments: { description: "updated via admin scope" },
6602
+ },
6603
+ }),
6604
+ });
6605
+
6606
+ const res = await handleScopedMcp(req, vaultName, {
6607
+ permission: "full",
6608
+ scopes: ["vault:read", "vault:write", "vault:admin"],
6609
+ legacyDerived: false,
6610
+ scoped_tags: null,
6611
+ });
6612
+
6270
6613
  expect(res.status).toBe(200);
6271
6614
  const body = await res.json() as any;
6272
6615
  expect(body.result.isError).toBeFalsy();
6273
- expect(readVaultConfig(vaultName)?.description).toBe("updated via write scope");
6616
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via admin scope");
6274
6617
 
6275
6618
  closeAllStores();
6276
6619
  });
@@ -6406,15 +6749,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6406
6749
  return names;
6407
6750
  }
6408
6751
 
6409
- test("vault:read sees exactly the 4 read tools", async () => {
6752
+ test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
6410
6753
  const names = await listToolNames(["vault:read"]);
6411
6754
  expect(new Set(names)).toEqual(
6412
- new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
6755
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
6413
6756
  );
6414
- expect(names.length).toBe(4);
6757
+ expect(names.length).toBe(5);
6415
6758
  });
6416
6759
 
6417
- test("vault:read + vault:write sees the 11 read+write tools", async () => {
6760
+ test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
6418
6761
  const names = await listToolNames(["vault:read", "vault:write"]);
6419
6762
  expect(new Set(names)).toEqual(
6420
6763
  new Set([
@@ -6422,30 +6765,31 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6422
6765
  "list-tags",
6423
6766
  "find-path",
6424
6767
  "vault-info",
6768
+ "doctor",
6425
6769
  "create-note",
6426
6770
  "update-note",
6427
6771
  "delete-note",
6428
- "update-tag",
6429
- "delete-tag",
6430
- // vault#552: MCP parity with the pre-existing REST rename/merge engine.
6431
- "rename-tag",
6432
- "merge-tags",
6433
6772
  ]),
6434
6773
  );
6435
- expect(names.length).toBe(11);
6774
+ expect(names.length).toBe(8);
6436
6775
  expect(names).not.toContain("manage-token");
6437
- expect(names).not.toContain("doctor");
6438
- // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
6439
- // update). Only manage-token/prune-schema/doctor are admin-gated.
6776
+ // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6777
+ // admin-tier structure/taxonomy curation, not content authorship.
6778
+ // Only delete-note (content) stays write-tier.
6779
+ expect(names).not.toContain("update-tag");
6780
+ expect(names).not.toContain("delete-tag");
6781
+ expect(names).not.toContain("rename-tag");
6782
+ expect(names).not.toContain("merge-tags");
6440
6783
  expect(names).toContain("delete-note");
6441
- expect(names).toContain("delete-tag");
6442
6784
  });
6443
6785
 
6444
- test("vault:admin sees all 14 tools including manage-token + prune-schema + doctor", async () => {
6786
+ test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
6445
6787
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6446
6788
  expect(names).toContain("manage-token");
6447
6789
  expect(names).toContain("prune-schema");
6448
6790
  expect(names).toContain("doctor");
6791
+ expect(names).toContain("update-tag");
6792
+ expect(names).toContain("delete-tag");
6449
6793
  expect(names).toContain("rename-tag");
6450
6794
  expect(names).toContain("merge-tags");
6451
6795
  expect(names.length).toBe(14);
@@ -6535,6 +6879,144 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6535
6879
  });
6536
6880
  });
6537
6881
 
6882
+ // ===========================================================================
6883
+ // Write/admin re-tier — schema/taxonomy-curation tools moved write → admin,
6884
+ // doctor moved admin → read. Content-authorship (write) is now separate from
6885
+ // structure/taxonomy/schema-curation (admin). No new scope — same
6886
+ // read/write/admin vocabulary, only which tier each tool requires moves.
6887
+ // ===========================================================================
6888
+
6889
+ describe("MCP write/admin re-tier — scope enforcement at the tools/call layer", () => {
6890
+ async function callTool(
6891
+ vaultName: string,
6892
+ scopes: string[],
6893
+ name: string,
6894
+ args: Record<string, unknown>,
6895
+ ): Promise<any> {
6896
+ const { handleScopedMcp } = await import("./mcp-http.ts");
6897
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
6898
+ method: "POST",
6899
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
6900
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
6901
+ });
6902
+ const res = await handleScopedMcp(req, vaultName, {
6903
+ permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
6904
+ scopes,
6905
+ legacyDerived: false,
6906
+ scoped_tags: null,
6907
+ } as any);
6908
+ return res.json();
6909
+ }
6910
+
6911
+ test("vault:write CAN create-note but is DENIED rename-tag/merge-tags/delete-tag/update-tag (now admin-tier)", async () => {
6912
+ const { writeVaultConfig } = await import("./config.ts");
6913
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
6914
+
6915
+ const vaultName = `retier-write-${Date.now()}`;
6916
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
6917
+ const store = getVaultStore(vaultName);
6918
+ await store.createNote("seed", { tags: ["mine"] });
6919
+
6920
+ const WRITE = ["vault:read", "vault:write"];
6921
+
6922
+ // Allowed: content authorship.
6923
+ const create = await callTool(vaultName, WRITE, "create-note", { content: "hello", tags: ["mine"] });
6924
+ expect(create.result?.isError).toBeFalsy();
6925
+
6926
+ // Denied: each now requires vault:admin, not vault:write.
6927
+ const rename = await callTool(vaultName, WRITE, "rename-tag", { old_name: "mine", new_name: "mine2" });
6928
+ expect(rename.result?.isError).toBe(true);
6929
+ expect(rename.result.content[0].text).toContain("Unknown tool");
6930
+
6931
+ const merge = await callTool(vaultName, WRITE, "merge-tags", { sources: ["mine"], target: "mine2" });
6932
+ expect(merge.result?.isError).toBe(true);
6933
+ expect(merge.result.content[0].text).toContain("Unknown tool");
6934
+
6935
+ const del = await callTool(vaultName, WRITE, "delete-tag", { tag: "mine" });
6936
+ expect(del.result?.isError).toBe(true);
6937
+ expect(del.result.content[0].text).toContain("Unknown tool");
6938
+
6939
+ const upd = await callTool(vaultName, WRITE, "update-tag", { tag: "mine", description: "hijacked" });
6940
+ expect(upd.result?.isError).toBe(true);
6941
+ expect(upd.result.content[0].text).toContain("Unknown tool");
6942
+
6943
+ // Untouched — the denied calls above never reached core. "mine" already
6944
+ // has a bare identity row (auto-inserted when the seed note was tagged),
6945
+ // but its description is still unset — update-tag never ran.
6946
+ expect((await store.getTagRecord("mine"))?.description ?? null).toBeNull();
6947
+ const stillThere = await store.listTags();
6948
+ expect(stillThere.some((t) => t.name === "mine")).toBe(true);
6949
+
6950
+ closeAllStores();
6951
+ });
6952
+
6953
+ test("vault:read CAN run doctor (now read-tier)", async () => {
6954
+ const { writeVaultConfig } = await import("./config.ts");
6955
+ const { closeAllStores } = await import("./vault-store.ts");
6956
+
6957
+ const vaultName = `retier-read-doctor-${Date.now()}`;
6958
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
6959
+
6960
+ const result = await callTool(vaultName, ["vault:read"], "doctor", {});
6961
+ expect(result.result?.isError).toBeFalsy();
6962
+ const report = JSON.parse(result.result.content[0].text);
6963
+ expect(report.findings).toBeDefined();
6964
+ expect(report.summary).toBeDefined();
6965
+
6966
+ closeAllStores();
6967
+ });
6968
+
6969
+ test("vault:write is DENIED the vault-info description write (now admin-tier)", async () => {
6970
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6971
+ const { closeAllStores } = await import("./vault-store.ts");
6972
+
6973
+ const vaultName = `retier-write-vaultinfo-${Date.now()}`;
6974
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
6975
+
6976
+ const result = await callTool(vaultName, ["vault:read", "vault:write"], "vault-info", { description: "nope" });
6977
+ expect(result.result?.isError).toBe(true);
6978
+ expect(result.result.content[0].text).toContain("vault:admin");
6979
+ expect(readVaultConfig(vaultName)?.description).toBe("orig");
6980
+
6981
+ closeAllStores();
6982
+ });
6983
+
6984
+ test("vault:admin CAN do all of the above — create-note, rename/merge/delete/update-tag, doctor, and the vault-info description write", async () => {
6985
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6986
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
6987
+
6988
+ const vaultName = `retier-admin-${Date.now()}`;
6989
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
6990
+ const store = getVaultStore(vaultName);
6991
+ await store.upsertTagRecord("a", {});
6992
+ await store.upsertTagRecord("b", {});
6993
+ await store.createNote("seed", { tags: ["a"] });
6994
+
6995
+ const ADMIN = ["vault:read", "vault:write", "vault:admin"];
6996
+
6997
+ const create = await callTool(vaultName, ADMIN, "create-note", { content: "hello", tags: ["a"] });
6998
+ expect(create.result?.isError).toBeFalsy();
6999
+
7000
+ const upd = await callTool(vaultName, ADMIN, "update-tag", { tag: "a", description: "updated" });
7001
+ expect(upd.result?.isError).toBeFalsy();
7002
+
7003
+ const doctor = await callTool(vaultName, ADMIN, "doctor", {});
7004
+ expect(doctor.result?.isError).toBeFalsy();
7005
+
7006
+ const vinfo = await callTool(vaultName, ADMIN, "vault-info", { description: "updated via admin" });
7007
+ expect(vinfo.result?.isError).toBeFalsy();
7008
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via admin");
7009
+
7010
+ const merge = await callTool(vaultName, ADMIN, "merge-tags", { sources: ["b"], target: "a" });
7011
+ expect(merge.result?.isError).toBeFalsy();
7012
+
7013
+ const del = await callTool(vaultName, ADMIN, "delete-tag", { tag: "a" });
7014
+ expect(del.result?.isError).toBeFalsy();
7015
+
7016
+ closeAllStores();
7017
+ });
7018
+ });
7019
+
6538
7020
  // ===========================================================================
6539
7021
  // vault#376 — Change 2: manage-token mint/revoke/list
6540
7022
  // ===========================================================================
@@ -7317,3 +7799,57 @@ describe("handleVault: transcription capability (scribe-fold Phase 1)", async ()
7317
7799
  });
7318
7800
  });
7319
7801
 
7802
+ describe("handleVault: front-door structural map", async () => {
7803
+ test("GET always includes `map` — no ?include_stats needed", async () => {
7804
+ await store.createNote("a", { tags: ["person"], path: "People/Alice" });
7805
+ await store.createNote("b", { tags: ["person"] }); // no path
7806
+
7807
+ const cfg = { name: "default" } as { name: string };
7808
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
7809
+ expect(res.status).toBe(200);
7810
+ const body = await res.json() as any;
7811
+ expect(body.stats).toBeUndefined();
7812
+ expect(body.map).toBeTruthy();
7813
+ expect(body.map.total_notes).toBe(2);
7814
+ expect(body.map.tags).toEqual([{ name: "person", count: 2 }]);
7815
+ expect(body.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
7816
+ expect(body.map.unfiled_notes).toBe(1);
7817
+ });
7818
+
7819
+ test("?include_stats=true adds stats ALONGSIDE map, not instead of it", async () => {
7820
+ await store.createNote("a", { tags: ["person"] });
7821
+ const cfg = { name: "default" } as { name: string };
7822
+ const res = await handleVault(mkReq("GET", "/vault?include_stats=true"), store, cfg as any);
7823
+ const body = await res.json() as any;
7824
+ expect(body.stats).toBeTruthy();
7825
+ expect(body.map).toBeTruthy();
7826
+ });
7827
+
7828
+ test("a tag-scoped caller's map covers only notes reachable through an in-scope tag", async () => {
7829
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
7830
+ await store.createNote("b", { tags: ["work"] }); // no path
7831
+ await store.createNote("c", { tags: ["personal"], path: "Personal/Two" });
7832
+
7833
+ const cfg = { name: "default" } as { name: string };
7834
+ const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["work"]), raw: ["work"] };
7835
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
7836
+ const body = await res.json() as any;
7837
+
7838
+ expect(body.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
7839
+ expect(body.map.tags).toEqual([{ name: "work", count: 2 }]);
7840
+ expect(body.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
7841
+ expect(body.map.unfiled_notes).toBe(1);
7842
+ });
7843
+
7844
+ test("a tag-scoped caller whose allowlist matches nothing gets an all-zero map, not the full vault", async () => {
7845
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
7846
+
7847
+ const cfg = { name: "default" } as { name: string };
7848
+ const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["nonexistent"]), raw: ["nonexistent"] };
7849
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
7850
+ const body = await res.json() as any;
7851
+
7852
+ expect(body.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
7853
+ });
7854
+ });
7855
+