@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/store.ts CHANGED
@@ -15,10 +15,12 @@ import { pathTitle } from "./paths.js";
15
15
  import { HookRegistry } from "./hooks.js";
16
16
  import {
17
17
  loadTagHierarchy,
18
- getTagDescendants,
18
+ getTagExpansion,
19
19
  TAG_CONFIG_PREFIX,
20
20
  DEFAULT_TAG_NAME,
21
+ DEFAULT_TAG_EXPAND_MODE,
21
22
  type TagHierarchy,
23
+ type TagExpandMode,
22
24
  } from "./tag-hierarchy.js";
23
25
  import {
24
26
  loadSchemaConfig,
@@ -278,13 +280,25 @@ export class BunSqliteStore implements Store {
278
280
  * the other tags' notes — wrong).
279
281
  *
280
282
  * Other filters (path, metadata, dates) still apply in both cases.
283
+ *
284
+ * `expand` axis (vault tag `expand` axis): `opts.expand` selects WHICH axis
285
+ * each tag expands along — `"subtypes"` (default, the parent_names path
286
+ * documented above, with the `_default` magic), `"namespace"` (lexical
287
+ * `tag/*`), `"both"` (union), or `"exact"` (no expansion). The `_default`
288
+ * universal-parent magic is a SUBTYPES-axis concept, so it fires only when
289
+ * the resolved mode includes subtypes (`"subtypes"`/`"both"`); under
290
+ * `"namespace"`/`"exact"` a literal `_default` tag is treated like any other.
281
291
  */
282
292
  private expandQueryTags(opts: QueryOpts): QueryOpts {
283
293
  if (!opts.tags || opts.tags.length === 0) return opts;
284
294
  const hierarchy = this.getTagHierarchy();
295
+ const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
296
+ const subtypeAxis = mode === "subtypes" || mode === "both";
285
297
 
286
298
  let tags = opts.tags;
287
- if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
299
+ // `_default` collapse only applies on the subtypes axis — it's the
300
+ // universal *parent* (an is-a relationship), not a namespace prefix.
301
+ if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
288
302
  const match = opts.tagMatch ?? "all";
289
303
  if (match === "any") {
290
304
  const { tags: _drop, ..._rest } = opts;
@@ -298,34 +312,61 @@ export class BunSqliteStore implements Store {
298
312
  opts = { ...opts, tags };
299
313
  }
300
314
 
301
- if (hierarchy.childrenOf.size === 0) return opts;
302
- const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
315
+ // Subtypes fast-path: with no declared hierarchy there are no descendants,
316
+ // so the engine's `[tag]` fallback already produces the literal-tag join —
317
+ // skip attaching `_tagsExpanded` to stay byte-identical to pre-axis
318
+ // behavior. `exact` likewise needs no expansion. Namespace/both must still
319
+ // run (lexical expansion is independent of `parent_names`).
320
+ if (mode === "exact") return opts;
321
+ if (mode === "subtypes" && hierarchy.childrenOf.size === 0) return opts;
322
+
323
+ const expanded = tags.map((t) => Array.from(getTagExpansion(hierarchy, t, mode)));
303
324
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
304
325
  }
305
326
 
306
- async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
307
- // Same hierarchy-expansion treatment as queryNotes searching `#manual`
308
- // should match notes tagged with any descendant tag. The underlying
309
- // FTS path already uses `IN (...)` for tags, so we flatten the
310
- // per-input expansions into a single union (search semantics are
311
- // "any tag matches"). When `_default` is among the requested tags
312
- // (and a `_default` row exists), the OR collapses to "every note" —
313
- // drop the tag filter entirely so the search hits the full corpus
314
- // and untagged notes are reachable.
327
+ async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
328
+ // Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
329
+ // (vault tag `expand` axis) searching `#manual` should match notes
330
+ // tagged with any descendant under "subtypes", any `manual/*` under
331
+ // "namespace", etc. The underlying FTS path already uses `IN (...)` for
332
+ // tags, so we flatten the per-input expansions into a single union (search
333
+ // semantics are "any tag matches").
334
+ //
335
+ // `_default` collapse is a SUBTYPES-axis concept (the universal *parent*):
336
+ // when `_default` is among the requested tags and a `_default` row exists,
337
+ // the OR collapses to "every note" — drop the tag filter entirely so the
338
+ // search hits the full corpus and untagged notes are reachable. It fires
339
+ // only on the subtypes/both axes (mirrors `expandQueryTags`).
315
340
  if (opts?.tags && opts.tags.length > 0) {
341
+ const mode: TagExpandMode = opts.expand ?? DEFAULT_TAG_EXPAND_MODE;
342
+ const subtypeAxis = mode === "subtypes" || mode === "both";
316
343
  const hierarchy = this.getTagHierarchy();
317
- if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
318
- const { tags: _drop, ..._rest } = opts;
344
+ if (subtypeAxis && hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
345
+ const { tags: _drop, expand: _e, ..._rest } = opts;
319
346
  return noteOps.searchNotes(this.db, query, _rest);
320
347
  }
321
- if (hierarchy.childrenOf.size > 0) {
348
+ // Subtypes fast-path: with no declared hierarchy there are no
349
+ // descendants, so the tags pass through unchanged (byte-identical to
350
+ // pre-axis behavior). `exact` likewise needs no expansion.
351
+ // Namespace/both must still run (lexical expansion is independent of
352
+ // `parent_names`).
353
+ const skipExpansion =
354
+ mode === "exact" || (mode === "subtypes" && hierarchy.childrenOf.size === 0);
355
+ if (!skipExpansion) {
322
356
  const expanded = new Set<string>();
323
357
  for (const t of opts.tags) {
324
- for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
358
+ for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
325
359
  }
326
- return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
360
+ const { expand: _e, ..._rest } = opts;
361
+ return noteOps.searchNotes(this.db, query, { ..._rest, tags: Array.from(expanded) });
327
362
  }
328
363
  }
364
+ // Strip the internal `expand` before passing to noteOps (it has no field
365
+ // for it; harmless but keep the boundary clean).
366
+ if (opts && "expand" in opts) {
367
+ const { expand: _e, ..._rest } = opts;
368
+ return noteOps.searchNotes(this.db, query, _rest);
369
+ }
329
370
  return noteOps.searchNotes(this.db, query, opts);
330
371
  }
331
372
 
@@ -340,11 +381,17 @@ export class BunSqliteStore implements Store {
340
381
  }
341
382
 
342
383
  async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
384
+ // Thin `mode:"subtypes"` shim over the mode-aware `expandTags`, so existing
385
+ // callers (tag-scope auth, search) keep the exact descendant semantics.
386
+ return this.expandTags(tags, "subtypes");
387
+ }
388
+
389
+ async expandTags(tags: string[], mode: TagExpandMode = DEFAULT_TAG_EXPAND_MODE): Promise<Set<string>> {
343
390
  const expanded = new Set<string>();
344
391
  if (tags.length === 0) return expanded;
345
392
  const hierarchy = this.getTagHierarchy();
346
393
  for (const t of tags) {
347
- for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
394
+ for (const x of getTagExpansion(hierarchy, t, mode)) expanded.add(x);
348
395
  }
349
396
  return expanded;
350
397
  }
@@ -572,7 +619,7 @@ export class BunSqliteStore implements Store {
572
619
  patch: {
573
620
  description?: string | null;
574
621
  fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
575
- relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
622
+ relationships?: tagSchemaOps.TagRelationshipMap | null;
576
623
  parent_names?: string[] | null;
577
624
  },
578
625
  ) {
@@ -730,7 +777,7 @@ export class BunSqliteStore implements Store {
730
777
  // Scope by noteId so a token authorized for note A can't delete note B's attachments.
731
778
  const row = this.db.prepare(
732
779
  "SELECT path FROM attachments WHERE id = ? AND note_id = ?",
733
- ).get(attachmentId, noteId) as { path: string } | undefined;
780
+ ).get(attachmentId, noteId) as { path: string } | null;
734
781
  if (!row) return { deleted: false, path: null, orphaned: false };
735
782
 
736
783
  this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
@@ -756,7 +803,7 @@ export class BunSqliteStore implements Store {
756
803
  async getAttachment(attachmentId: string): Promise<Attachment | null> {
757
804
  const row = this.db.prepare(
758
805
  "SELECT * FROM attachments WHERE id = ?",
759
- ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | undefined;
806
+ ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | null;
760
807
  if (!row) return null;
761
808
  let metadata: Record<string, unknown> | undefined;
762
809
  if (row.metadata && row.metadata !== "{}") {
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Tag-expansion axis (`expand`) tests — vault tag `expand` axis
3
+ * (design `design/2026-06-09-tag-expand-axis.md`).
4
+ *
5
+ * Covers the query engine (`store.queryNotes` → `expandQueryTags` →
6
+ * `_tagsExpanded`) and the mode-aware core helpers
7
+ * (`getTagExpansion`/`getTagNamespace`). REST + MCP + SSE parity are exercised
8
+ * in their own suites (`routes`/`mcp` here, `subscribe`/`live-match` in src/).
9
+ *
10
+ * The corpus deliberately separates the two axes so a mode that confuses them
11
+ * fails loudly:
12
+ * - `entity` is the SUBTYPE parent of `person` (declared via parent_names).
13
+ * `person` is NOT name-prefixed `entity/`.
14
+ * - `entity/archived` is NAME-prefixed under `entity` but is NOT a declared
15
+ * subtype (no parent_names link to `entity`).
16
+ * - `entity/person` is BOTH a declared subtype-child of `entity` AND
17
+ * name-prefixed `entity/` — the dedupe case.
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach } from "bun:test";
21
+ import { Database } from "bun:sqlite";
22
+ import { SqliteStore } from "./store.js";
23
+ import {
24
+ loadTagHierarchy,
25
+ getTagExpansion,
26
+ getTagNamespace,
27
+ getTagDescendants,
28
+ } from "./tag-hierarchy.js";
29
+
30
+ let store: SqliteStore;
31
+ let db: Database;
32
+
33
+ /**
34
+ * Seed the two-axis corpus and return the set of note ids by their kind so
35
+ * tests can assert membership without depending on insertion order.
36
+ */
37
+ async function seedTwoAxisCorpus(s: SqliteStore) {
38
+ // SUBTYPE axis: person is-a entity (declared), but NOT filed under entity/.
39
+ await s.upsertTagRecord("entity", { description: "an entity" });
40
+ await s.upsertTagRecord("person", { parent_names: ["entity"] });
41
+ // NAMESPACE axis: entity/archived is filed under entity/ but declares no
42
+ // parent_names (pure filing, no is-a edge).
43
+ await s.upsertTagRecord("entity/archived", {});
44
+ // BOTH axes: entity/person is a declared subtype-child AND name-prefixed.
45
+ await s.upsertTagRecord("entity/person", { parent_names: ["entity"] });
46
+
47
+ const nEntity = await s.createNote("literal entity", { tags: ["entity"] });
48
+ const nPerson = await s.createNote("a person (subtype)", { tags: ["person"] });
49
+ const nArchived = await s.createNote("filed under entity/", { tags: ["entity/archived"] });
50
+ const nBoth = await s.createNote("subtype AND filed", { tags: ["entity/person"] });
51
+ const nUnrelated = await s.createNote("unrelated", { tags: ["work"] });
52
+
53
+ return {
54
+ entity: nEntity.id,
55
+ person: nPerson.id,
56
+ archived: nArchived.id,
57
+ both: nBoth.id,
58
+ unrelated: nUnrelated.id,
59
+ };
60
+ }
61
+
62
+ const idsOf = (notes: { id: string }[]) => new Set(notes.map((n) => n.id));
63
+
64
+ beforeEach(() => {
65
+ db = new Database(":memory:");
66
+ store = new SqliteStore(db);
67
+ });
68
+
69
+ describe("tag expand axis — core helpers", () => {
70
+ it("getTagNamespace returns the tag + lexically prefixed names, no parent_names subtypes", async () => {
71
+ await seedTwoAxisCorpus(store);
72
+ const h = loadTagHierarchy(db);
73
+ const ns = getTagNamespace(h, "entity");
74
+ // tag itself + name-prefixed entity/*
75
+ expect(ns).toContain("entity");
76
+ expect(ns).toContain("entity/archived");
77
+ expect(ns).toContain("entity/person");
78
+ // `person` is a subtype but NOT name-prefixed → must be absent.
79
+ expect(ns.has("person")).toBe(false);
80
+ });
81
+
82
+ it("getTagExpansion: subtypes = descendants, namespace = lexical, both = union, exact = literal", async () => {
83
+ await seedTwoAxisCorpus(store);
84
+ const h = loadTagHierarchy(db);
85
+
86
+ const subtypes = getTagExpansion(h, "entity", "subtypes");
87
+ // descendants via parent_names: entity, person, entity/person — NOT entity/archived
88
+ expect(subtypes).toEqual(getTagDescendants(h, "entity"));
89
+ expect(subtypes).toContain("person");
90
+ expect(subtypes).toContain("entity/person");
91
+ expect(subtypes.has("entity/archived")).toBe(false);
92
+
93
+ const ns = getTagExpansion(h, "entity", "namespace");
94
+ expect(ns).toContain("entity/archived");
95
+ expect(ns).toContain("entity/person");
96
+ expect(ns.has("person")).toBe(false);
97
+
98
+ const both = getTagExpansion(h, "entity", "both");
99
+ // union: subtype-only person + namespace-only entity/archived + shared
100
+ expect(both).toContain("person");
101
+ expect(both).toContain("entity/archived");
102
+ expect(both).toContain("entity/person");
103
+
104
+ const exact = getTagExpansion(h, "entity", "exact");
105
+ expect(exact).toEqual(new Set(["entity"]));
106
+ });
107
+
108
+ it("dedupe: entity/person (subtype AND name-prefixed) appears once under both", async () => {
109
+ await seedTwoAxisCorpus(store);
110
+ const h = loadTagHierarchy(db);
111
+ const both = getTagExpansion(h, "entity", "both");
112
+ const count = Array.from(both).filter((t) => t === "entity/person").length;
113
+ expect(count).toBe(1);
114
+ });
115
+
116
+ it("store.expandTags mirrors the helper modes and unions multi-tag input", async () => {
117
+ await seedTwoAxisCorpus(store);
118
+ expect(await store.expandTags(["entity"], "exact")).toEqual(new Set(["entity"]));
119
+ const ns = await store.expandTags(["entity"], "namespace");
120
+ expect(ns).toContain("entity/archived");
121
+ expect(ns.has("person")).toBe(false);
122
+ // default (no mode) === subtypes === expandTagsWithDescendants
123
+ const def = await store.expandTags(["entity"]);
124
+ expect(def).toEqual(await store.expandTagsWithDescendants(["entity"]));
125
+ });
126
+ });
127
+
128
+ describe("tag expand axis — query engine", () => {
129
+ it("subtypes (default / absent) is a pure regression: descendants only, no namespaced sibling", async () => {
130
+ const ids = await seedTwoAxisCorpus(store);
131
+
132
+ const absent = await store.queryNotes({ tags: ["entity"] });
133
+ const explicit = await store.queryNotes({ tags: ["entity"], expand: "subtypes" });
134
+
135
+ // Absent ≡ explicit "subtypes" — byte-identical result set.
136
+ expect(idsOf(explicit)).toEqual(idsOf(absent));
137
+
138
+ // Returns the literal + declared subtypes…
139
+ expect(idsOf(absent)).toEqual(new Set([ids.entity, ids.person, ids.both]));
140
+ // …and NOT the namespace-only sibling entity/archived.
141
+ expect(idsOf(absent).has(ids.archived)).toBe(false);
142
+ });
143
+
144
+ it("namespace returns tag + tag/* lexical, NOT parent_names-only subtypes", async () => {
145
+ const ids = await seedTwoAxisCorpus(store);
146
+ const res = await store.queryNotes({ tags: ["entity"], expand: "namespace" });
147
+ // entity (literal) + entity/archived + entity/person (name-prefixed)
148
+ expect(idsOf(res)).toEqual(new Set([ids.entity, ids.archived, ids.both]));
149
+ // `person` is a subtype but not name-prefixed → excluded.
150
+ expect(idsOf(res).has(ids.person)).toBe(false);
151
+ });
152
+
153
+ it("both = union of subtypes and namespace", async () => {
154
+ const ids = await seedTwoAxisCorpus(store);
155
+ const res = await store.queryNotes({ tags: ["entity"], expand: "both" });
156
+ expect(idsOf(res)).toEqual(
157
+ new Set([ids.entity, ids.person, ids.archived, ids.both]),
158
+ );
159
+ });
160
+
161
+ it("exact = literal tag only, no descendants", async () => {
162
+ const ids = await seedTwoAxisCorpus(store);
163
+ const res = await store.queryNotes({ tags: ["entity"], expand: "exact" });
164
+ expect(idsOf(res)).toEqual(new Set([ids.entity]));
165
+ });
166
+
167
+ it("dedupe: a note tagged with a both-axis tag is returned once under both", async () => {
168
+ await seedTwoAxisCorpus(store);
169
+ const res = await store.queryNotes({ tags: ["entity"], expand: "both" });
170
+ const bothCount = res.filter((n) => n.content === "subtype AND filed").length;
171
+ expect(bothCount).toBe(1);
172
+ });
173
+
174
+ it("_default magic stays subtypes-only: namespace on _default does NOT collapse to all notes", async () => {
175
+ // _default declared → universal subtype parent. With a namespaced child.
176
+ await store.upsertTagRecord("_default", { description: "universal" });
177
+ await store.upsertTagRecord("_default/scoped", {});
178
+ const nA = await store.createNote("a", { tags: ["alpha"] });
179
+ const nScoped = await store.createNote("scoped", { tags: ["_default/scoped"] });
180
+
181
+ // subtypes: _default expands to ALL tags → every note matches.
182
+ const sub = await store.queryNotes({ tags: ["_default"], expand: "subtypes" });
183
+ expect(idsOf(sub).has(nA.id)).toBe(true);
184
+
185
+ // namespace: _default is treated literally → only _default + _default/* —
186
+ // does NOT collapse to "all notes" (nA, tagged only `alpha`, is excluded).
187
+ const ns = await store.queryNotes({ tags: ["_default"], expand: "namespace" });
188
+ expect(idsOf(ns).has(nA.id)).toBe(false);
189
+ expect(idsOf(ns).has(nScoped.id)).toBe(true);
190
+ });
191
+
192
+ it("both + _default collapses to all-notes via the subtypes axis", async () => {
193
+ // `both` includes the subtypes axis, so the `_default` universal-parent
194
+ // magic must still fire — the union with the namespace axis can only widen
195
+ // the set, and subtypes alone already means "every note." Untagged notes
196
+ // included (the _default collapse drops the tag filter entirely).
197
+ await store.upsertTagRecord("_default", { description: "universal" });
198
+ const nTagged = await store.createNote("tagged", { tags: ["alpha"] });
199
+ const nUntagged = await store.createNote("untagged", {});
200
+
201
+ const res = await store.queryNotes({ tags: ["_default"], expand: "both" });
202
+ const got = idsOf(res);
203
+ expect(got.has(nTagged.id)).toBe(true);
204
+ expect(got.has(nUntagged.id)).toBe(true);
205
+ // Equivalent to the no-filter corpus.
206
+ const all = await store.queryNotes({});
207
+ expect(got).toEqual(idsOf(all));
208
+ });
209
+ });
210
+
211
+ describe("tag expand axis — MCP query-notes schema + handler", () => {
212
+ it("query-notes schema advertises the four expand values", async () => {
213
+ const { generateMcpTools } = await import("./mcp.js");
214
+ const tools = generateMcpTools(store);
215
+ const q = tools.find((t) => t.name === "query-notes")!;
216
+ const props = (q.inputSchema as any).properties;
217
+ expect(props.expand).toBeDefined();
218
+ expect(props.expand.enum).toEqual(["subtypes", "namespace", "both", "exact"]);
219
+ });
220
+
221
+ it("query-notes handler honors expand=namespace", async () => {
222
+ const { generateMcpTools } = await import("./mcp.js");
223
+ const ids = await seedTwoAxisCorpus(store);
224
+ const tools = generateMcpTools(store);
225
+ const q = tools.find((t) => t.name === "query-notes")!;
226
+ // Non-cursor structured query returns a flat array of note-index entries.
227
+ const res: any = await q.execute({ tag: "entity", expand: "namespace" });
228
+ const got = new Set<string>(res.map((n: any) => n.id));
229
+ expect(got).toEqual(new Set([ids.entity, ids.archived, ids.both]));
230
+ expect(got.has(ids.person)).toBe(false);
231
+ });
232
+
233
+ it("query-notes handler rejects an unknown expand value with INVALID_QUERY", async () => {
234
+ const { generateMcpTools } = await import("./mcp.js");
235
+ const tools = generateMcpTools(store);
236
+ const q = tools.find((t) => t.name === "query-notes")!;
237
+ let err: any;
238
+ try {
239
+ await q.execute({ tag: "entity", expand: "bogus" });
240
+ } catch (e) {
241
+ err = e;
242
+ }
243
+ expect(err).toBeDefined();
244
+ expect(err.code).toBe("INVALID_QUERY");
245
+ });
246
+ });
247
+
248
+ describe("tag expand axis — MCP search path honors expand", () => {
249
+ // Corpus shares the FTS term "fox" so search(tag=entity) differs ONLY by the
250
+ // expand axis — proving the search branch threads it into store.searchNotes.
251
+ async function seedSearchCorpus(s: SqliteStore) {
252
+ await s.upsertTagRecord("entity", { description: "entity root" });
253
+ await s.upsertTagRecord("person", { parent_names: ["entity"] }); // subtype, not name-prefixed
254
+ await s.upsertTagRecord("entity/archived", {}); // name-prefixed, not subtype
255
+ await s.createNote("fox literal", { tags: ["entity"] });
256
+ await s.createNote("fox subtype", { tags: ["person"] });
257
+ await s.createNote("fox filed", { tags: ["entity/archived"] });
258
+ await s.createNote("dog unrelated", { tags: ["entity"] }); // no "fox" → FTS excludes
259
+ }
260
+
261
+ it("search + tag, absent expand ≡ subtypes (descendants, no namespaced sibling)", async () => {
262
+ const { generateMcpTools } = await import("./mcp.js");
263
+ await seedSearchCorpus(store);
264
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
265
+ const absent: any = await q.execute({ search: "fox", tag: "entity", include_content: true });
266
+ const sub: any = await q.execute({ search: "fox", tag: "entity", expand: "subtypes", include_content: true });
267
+ const absentSet = new Set(absent.map((n: any) => n.content));
268
+ expect(new Set(sub.map((n: any) => n.content))).toEqual(absentSet);
269
+ expect(absentSet).toEqual(new Set(["fox literal", "fox subtype"]));
270
+ });
271
+
272
+ it("search + tag + expand=namespace returns lexical tag/*, NOT subtype sibling", async () => {
273
+ const { generateMcpTools } = await import("./mcp.js");
274
+ await seedSearchCorpus(store);
275
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
276
+ const res: any = await q.execute({ search: "fox", tag: "entity", expand: "namespace", include_content: true });
277
+ expect(new Set(res.map((n: any) => n.content))).toEqual(new Set(["fox literal", "fox filed"]));
278
+ });
279
+
280
+ it("search + tag + expand=exact returns only the literal-tagged match", async () => {
281
+ const { generateMcpTools } = await import("./mcp.js");
282
+ await seedSearchCorpus(store);
283
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
284
+ const res: any = await q.execute({ search: "fox", tag: "entity", expand: "exact", include_content: true });
285
+ expect(res.map((n: any) => n.content)).toEqual(["fox literal"]);
286
+ });
287
+
288
+ it("search + expand=bogus is rejected with INVALID_QUERY before the search runs", async () => {
289
+ const { generateMcpTools } = await import("./mcp.js");
290
+ await store.createNote("fox here", { tags: ["entity"] });
291
+ const q = generateMcpTools(store).find((t) => t.name === "query-notes")!;
292
+ let err: any;
293
+ try {
294
+ await q.execute({ search: "fox", tag: "entity", expand: "bogus" });
295
+ } catch (e) {
296
+ err = e;
297
+ }
298
+ expect(err).toBeDefined();
299
+ expect(err.code).toBe("INVALID_QUERY");
300
+ });
301
+ });
@@ -143,6 +143,86 @@ export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
143
143
  return result;
144
144
  }
145
145
 
146
+ /**
147
+ * Tag-expansion axis (vault tag `expand` axis — design
148
+ * `design/2026-06-09-tag-expand-axis.md`). A tag relates to others along two
149
+ * orthogonal axes; this selects which one (or both, or neither) a query expands
150
+ * along:
151
+ *
152
+ * - `"subtypes"` (DEFAULT): tag ∪ `parent_names` descendants. The semantic
153
+ * is-a axis — today's always-on behavior, unchanged. `_default` universal
154
+ * parent magic fires here.
155
+ * - `"namespace"`: tag ∪ {names lexically prefixed `tag/`}. The organizational
156
+ * filing axis — purely lexical over the known tag set, no `parent_names`, no
157
+ * `_default` magic.
158
+ * - `"both"`: union of subtypes and namespace.
159
+ * - `"exact"`: the literal tag only, no expansion.
160
+ */
161
+ export type TagExpandMode = "subtypes" | "namespace" | "both" | "exact";
162
+
163
+ export const TAG_EXPAND_MODES: readonly TagExpandMode[] = [
164
+ "subtypes",
165
+ "namespace",
166
+ "both",
167
+ "exact",
168
+ ] as const;
169
+
170
+ export const DEFAULT_TAG_EXPAND_MODE: TagExpandMode = "subtypes";
171
+
172
+ /**
173
+ * Lexical namespace expansion for a single tag: the tag itself plus every
174
+ * known tag name filed under it (`name === tag` OR `name` starts with
175
+ * `tag + "/"`). Purely a string-prefix match over `h.allTags` — namespacing is
176
+ * free-form (the slash in the tag *name*), declared nowhere, which is the whole
177
+ * point: subtyping is declared via `parent_names`, filing is not. No `_default`
178
+ * magic here — that's a subtypes-axis concept.
179
+ */
180
+ export function getTagNamespace(h: TagHierarchy, tag: string): Set<string> {
181
+ // Pre-seeded with `tag` itself, so the loop only needs the strict `tag/*`
182
+ // prefix test (the `name === tag` case is already covered by the seed).
183
+ const result = new Set<string>([tag]);
184
+ const prefix = tag + "/";
185
+ for (const name of h.allTags) {
186
+ if (name.startsWith(prefix)) result.add(name);
187
+ }
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * Mode-aware single-tag expansion (vault tag `expand` axis). Returns the set of
193
+ * tag names a query for `tag` should match under `mode`. Always includes `tag`
194
+ * itself. Set semantics: a tag that is BOTH a declared subtype-child AND
195
+ * name-prefixed appears once.
196
+ *
197
+ * - `"subtypes"` → `getTagDescendants` (parent_names + `_default` magic).
198
+ * - `"namespace"` → `getTagNamespace` (lexical `tag/*`).
199
+ * - `"both"` → union of the two.
200
+ * - `"exact"` → `{tag}`.
201
+ */
202
+ export function getTagExpansion(
203
+ h: TagHierarchy,
204
+ tag: string,
205
+ mode: TagExpandMode,
206
+ ): Set<string> {
207
+ switch (mode) {
208
+ case "exact":
209
+ return new Set<string>([tag]);
210
+ case "namespace":
211
+ return getTagNamespace(h, tag);
212
+ case "both": {
213
+ const union = new Set<string>(getTagDescendants(h, tag));
214
+ for (const name of getTagNamespace(h, tag)) union.add(name);
215
+ return union;
216
+ }
217
+ case "subtypes":
218
+ default:
219
+ // `default` is a defensive fallback — `TagExpandMode` already constrains
220
+ // the value to the four cases above; it can only be reached if an
221
+ // untyped caller passes a bad string.
222
+ return getTagDescendants(h, tag);
223
+ }
224
+ }
225
+
146
226
  /**
147
227
  * Detect cycles in the declared hierarchy. Returns the list of tags
148
228
  * reachable from themselves via parent declarations. Used by