@openparachute/vault 0.6.4-rc.13 → 0.6.4-rc.14

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.
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { generateMcpTools } from "./mcp.js";
5
+ import { stripTagHash } from "./tag-hierarchy.js";
6
+
7
+ // Canonical-bare-tag guard (PR #516). A `#`-prefixed tag must never be stored
8
+ // or mismatched: the agent module stored `#agent/message/inbound` literally and
9
+ // consumers querying the bare `agent/message/inbound` convention found nothing.
10
+ // The guard strips a leading `#` (idempotently) on BOTH the write path and the
11
+ // query path, while preserving casing + structure (it is NOT normalizeTagValue).
12
+
13
+ let store: SqliteStore;
14
+ let db: Database;
15
+
16
+ beforeEach(() => {
17
+ db = new Database(":memory:");
18
+ store = new SqliteStore(db);
19
+ });
20
+
21
+ /** Raw read of a note's stored tag rows (bypasses any hydration). */
22
+ function storedTags(noteId: string): string[] {
23
+ return (
24
+ db
25
+ .prepare("SELECT tag_name FROM note_tags WHERE note_id = ? ORDER BY tag_name")
26
+ .all(noteId) as { tag_name: string }[]
27
+ ).map((r) => r.tag_name);
28
+ }
29
+
30
+ describe("stripTagHash helper", () => {
31
+ it("strips a single leading #", () => {
32
+ expect(stripTagHash("#agent/message")).toBe("agent/message");
33
+ });
34
+
35
+ it("is idempotent across one or more leading # (##x → x, x → x)", () => {
36
+ expect(stripTagHash("##x")).toBe("x");
37
+ expect(stripTagHash("###deep")).toBe("deep");
38
+ expect(stripTagHash("x")).toBe("x");
39
+ expect(stripTagHash(stripTagHash("#x"))).toBe("x");
40
+ });
41
+
42
+ it("does NOT lowercase or otherwise transform — casing + structure preserved", () => {
43
+ expect(stripTagHash("#Agent/Message/Inbound")).toBe("Agent/Message/Inbound");
44
+ expect(stripTagHash("CamelCase")).toBe("CamelCase");
45
+ });
46
+
47
+ it("only strips LEADING # — a mid-string # (e.g. c#) is left intact", () => {
48
+ expect(stripTagHash("c#")).toBe("c#");
49
+ expect(stripTagHash("#c#")).toBe("c#");
50
+ expect(stripTagHash("a#b")).toBe("a#b");
51
+ });
52
+
53
+ it("trims surrounding whitespace before stripping", () => {
54
+ expect(stripTagHash(" #tag ")).toBe("tag");
55
+ });
56
+ });
57
+
58
+ describe("write path — tags stored bare", () => {
59
+ it("createNote: #agent/message stored as agent/message", async () => {
60
+ const note = await store.createNote("inbound", { tags: ["#agent/message"] });
61
+ expect(storedTags(note.id)).toEqual(["agent/message"]);
62
+ expect(note.tags).toEqual(["agent/message"]);
63
+ });
64
+
65
+ it("##x stored as x; a bare foo is unchanged", async () => {
66
+ const note = await store.createNote("memo", { tags: ["##x", "foo"] });
67
+ expect(storedTags(note.id).sort()).toEqual(["foo", "x"]);
68
+ });
69
+
70
+ it("a # mid-string (c#) is NOT mangled", async () => {
71
+ const note = await store.createNote("memo", { tags: ["c#"] });
72
+ expect(storedTags(note.id)).toEqual(["c#"]);
73
+ });
74
+
75
+ it("casing is preserved through the write path", async () => {
76
+ const note = await store.createNote("memo", { tags: ["#Agent/Inbound"] });
77
+ expect(storedTags(note.id)).toEqual(["Agent/Inbound"]);
78
+ });
79
+
80
+ it("tagNote: #-prefixed add lands on the bare row", async () => {
81
+ const note = await store.createNote("memo");
82
+ await store.tagNote(note.id, ["#later"]);
83
+ expect(storedTags(note.id)).toEqual(["later"]);
84
+ });
85
+
86
+ it("untagNote: removing #tag deletes the bare row", async () => {
87
+ const note = await store.createNote("memo", { tags: ["later"] });
88
+ await store.untagNote(note.id, ["#later"]);
89
+ expect(storedTags(note.id)).toEqual([]);
90
+ });
91
+
92
+ it("batchTag/batchUntag normalize independently of tagNote", async () => {
93
+ const a = await store.createNote("a");
94
+ const b = await store.createNote("b");
95
+ await store.batchTag([a.id, b.id], ["#batch"]);
96
+ expect(storedTags(a.id)).toEqual(["batch"]);
97
+ expect(storedTags(b.id)).toEqual(["batch"]);
98
+ await store.batchUntag([a.id], ["#batch"]);
99
+ expect(storedTags(a.id)).toEqual([]);
100
+ expect(storedTags(b.id)).toEqual(["batch"]);
101
+ });
102
+
103
+ it("create-note via MCP also stores bare", async () => {
104
+ const tools = generateMcpTools(store);
105
+ const createNote = tools.find((t) => t.name === "create-note")!;
106
+ const res = (await createNote.execute({
107
+ content: "mcp inbound",
108
+ tags: ["#agent/message/inbound"],
109
+ })) as { id: string };
110
+ expect(storedTags(res.id)).toEqual(["agent/message/inbound"]);
111
+ });
112
+ });
113
+
114
+ describe("query path — # and bare forms equivalent", () => {
115
+ it("query tag:#agent/message and tag:agent/message both match the same bare-stored note", async () => {
116
+ const note = await store.createNote("inbound", { tags: ["agent/message"] });
117
+
118
+ const byHash = await store.queryNotes({ tags: ["#agent/message"] });
119
+ expect(byHash.map((n) => n.id)).toContain(note.id);
120
+
121
+ const byBare = await store.queryNotes({ tags: ["agent/message"] });
122
+ expect(byBare.map((n) => n.id)).toContain(note.id);
123
+ });
124
+
125
+ it("an old #-form query reaches a #-decorated WRITE (both normalize to the same bare row)", async () => {
126
+ // The non-breaking guarantee: a client that writes AND queries with the
127
+ // legacy #-form still works — both sides converge on the bare row. (Truly
128
+ // pre-fix #-STORED data is migrated away via renameTag, not auto-reached.)
129
+ const note = await store.createNote("inbound", { tags: ["#agent/message"] });
130
+ const byHash = await store.queryNotes({ tags: ["#agent/message"] });
131
+ expect(byHash.map((n) => n.id)).toContain(note.id);
132
+ const byBare = await store.queryNotes({ tags: ["agent/message"] });
133
+ expect(byBare.map((n) => n.id)).toContain(note.id);
134
+ });
135
+
136
+ it("exclude_tags #x excludes x-tagged notes", async () => {
137
+ const kept = await store.createNote("kept", { tags: ["keep"] });
138
+ const dropped = await store.createNote("dropped", { tags: ["keep", "x"] });
139
+
140
+ const results = await store.queryNotes({ tags: ["keep"], excludeTags: ["#x"] });
141
+ const ids = results.map((n) => n.id);
142
+ expect(ids).toContain(kept.id);
143
+ expect(ids).not.toContain(dropped.id);
144
+ });
145
+
146
+ it("searchNotes treats #tag and bare tag identically", async () => {
147
+ const note = await store.createNote("findable text here", { tags: ["agent/message"] });
148
+ const hits = await store.searchNotes("findable", { tags: ["#agent/message"] });
149
+ expect(hits.map((n) => n.id)).toContain(note.id);
150
+ });
151
+
152
+ it("query-notes via MCP: #-form matches bare-stored note", async () => {
153
+ const note = await store.createNote("mcp query", { tags: ["agent/message/inbound"] });
154
+ const tools = generateMcpTools(store);
155
+ const query = tools.find((t) => t.name === "query-notes")!;
156
+ // query-notes returns a plain array when no cursor is requested.
157
+ const res = (await query.execute({ tag: "#agent/message/inbound" })) as { id: string }[];
158
+ expect(res.map((n) => n.id)).toContain(note.id);
159
+ });
160
+ });
161
+
162
+ describe("tag schema path — update-tag name + parent_names stored bare", () => {
163
+ it("update-tag with name #foo + parent_names [#bar] → stored bare; inheritance works", async () => {
164
+ const tools = generateMcpTools(store);
165
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
166
+
167
+ // Establish `bar` as a parent, then `foo` as its child via #-decorated input.
168
+ await updateTag.execute({ tag: "#bar", description: "parent" });
169
+ await updateTag.execute({ tag: "#foo", parent_names: ["#bar"] });
170
+
171
+ const fooRecord = await store.getTagRecord("foo");
172
+ expect(fooRecord).not.toBeNull();
173
+ expect(fooRecord!.parent_names).toEqual(["bar"]);
174
+ // The #-decorated upsert must NOT have created a phantom `#foo` row.
175
+ expect(await store.getTagRecord("#foo")).toBeNull();
176
+
177
+ // Inheritance: tag:bar expands to foo (foo's parent is bar).
178
+ const fooNote = await store.createNote("a foo note", { tags: ["foo"] });
179
+ const expanded = await store.queryNotes({ tags: ["bar"] });
180
+ expect(expanded.map((n) => n.id)).toContain(fooNote.id);
181
+ });
182
+
183
+ it("update-tag with #-name preserves the existing bare record's fields (merge correctness)", async () => {
184
+ const tools = generateMcpTools(store);
185
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
186
+
187
+ await updateTag.execute({ tag: "thing", description: "first" });
188
+ // A #-decorated follow-up upsert must read the EXISTING bare record so the
189
+ // partial merge doesn't clobber the prior description.
190
+ await updateTag.execute({ tag: "#thing", parent_names: [] });
191
+
192
+ const record = await store.getTagRecord("thing");
193
+ expect(record!.description).toBe("first");
194
+ });
195
+ });
196
+
197
+ describe("rename carve-out — source name stays literal (migration escape hatch)", () => {
198
+ it("renameTag(#agent/message, agent/message) finds + renames the #-prefixed row", async () => {
199
+ // Plant a legacy #-prefixed tag row + a note carrying it (simulating the
200
+ // pre-fix agent data the migration must rename away).
201
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
202
+ const note = await store.createNote("legacy inbound");
203
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
204
+
205
+ const result = await store.renameTag("#agent/message", "agent/message");
206
+ expect("renamed" in result).toBe(true);
207
+
208
+ // The literal #-row is gone; the note now carries the bare tag.
209
+ expect(storedTags(note.id)).toEqual(["agent/message"]);
210
+ const tags = await store.listTags();
211
+ expect(tags.map((t) => t.name)).not.toContain("#agent/message");
212
+ expect(tags.map((t) => t.name)).toContain("agent/message");
213
+ });
214
+
215
+ it("renameTag normalizes the TARGET — renaming TO a #-name stores bare (can't re-introduce #)", async () => {
216
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('plainsrc')").run();
217
+ const note = await store.createNote("x");
218
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'plainsrc')").run(note.id);
219
+
220
+ await store.renameTag("plainsrc", "#shiny");
221
+
222
+ // The target #-prefix is stripped — no `#shiny` row, the note carries `shiny`.
223
+ expect(storedTags(note.id)).toEqual(["shiny"]);
224
+ expect((await store.listTags()).map((t) => t.name)).not.toContain("#shiny");
225
+ });
226
+
227
+ it("mergeTags normalizes the TARGET — merging INTO a #-name stores bare", async () => {
228
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('m1')").run();
229
+ const note = await store.createNote("y");
230
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, 'm1')").run(note.id);
231
+
232
+ await store.mergeTags(["m1"], "#dest");
233
+
234
+ expect(storedTags(note.id)).toEqual(["dest"]);
235
+ expect((await store.listTags()).map((t) => t.name)).not.toContain("#dest");
236
+ });
237
+ });
238
+
239
+ describe("regression — existing bare-tag behavior unchanged", () => {
240
+ it("bare write + bare query still works end to end", async () => {
241
+ const note = await store.createNote("plain", { tags: ["alpha", "beta"] });
242
+ expect(storedTags(note.id).sort()).toEqual(["alpha", "beta"]);
243
+ const results = await store.queryNotes({ tags: ["alpha"] });
244
+ expect(results.map((n) => n.id)).toContain(note.id);
245
+ });
246
+ });
package/core/src/mcp.ts CHANGED
@@ -3,7 +3,7 @@ import type { Store, Note } from "./types.js";
3
3
  import * as noteOps from "./notes.js";
4
4
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
5
5
  import { QueryError } from "./query-operators.js";
6
- import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
6
+ import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "./tag-hierarchy.js";
7
7
  import * as linkOps from "./links.js";
8
8
  import * as tagSchemaOps from "./tag-schemas.js";
9
9
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -1453,7 +1453,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1453
1453
  required: ["tag"],
1454
1454
  },
1455
1455
  execute: async (params) => {
1456
- const tag = params.tag as string;
1456
+ // Canonical-bare-tag guard (PR #516): normalize the tag NAME up front
1457
+ // so the existing-record lookup (and the field/cross-tag merge that
1458
+ // depends on it) reads the bare `foo` row, not a phantom `#foo` miss.
1459
+ // store.upsertTagRecord normalizes again (idempotent) for non-MCP
1460
+ // callers; doing it here keeps the merge correct.
1461
+ const tag = stripTagHash(params.tag as string);
1457
1462
  const existing = tagSchemaOps.getTagRecord(db, tag);
1458
1463
 
1459
1464
  // ---- fields: three-way semantics, distinguishing `null` from
package/core/src/notes.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type QueryHashInputs,
20
20
  } from "./cursor.js";
21
21
  import { getIndexedField, releaseField } from "./indexed-fields.js";
22
+ import { stripTagHash } from "./tag-hierarchy.js";
22
23
 
23
24
  let idCounter = 0;
24
25
 
@@ -686,8 +687,14 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
686
687
  // rides idx_note_tags_tag, produces each note id at most once, and lets
687
688
  // the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
688
689
  if (opts.tags && opts.tags.length > 0) {
690
+ // Canonical-bare-tag guard (vault#XXX) backstop for direct-core callers
691
+ // that bypass BunSqliteStore.normalizeQueryTags (the store normalizes +
692
+ // hierarchy-expands before reaching here; this protects the raw noteOps
693
+ // entry point and tests). `_tagsExpanded`, when present, was already built
694
+ // from bare names by the store, so prefer it; otherwise strip the literal
695
+ // tags. No-op on already-bare input.
689
696
  const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
690
- ?? opts.tags.map((t) => [t]);
697
+ ?? opts.tags.map((t) => [stripTagHash(t)]);
691
698
  const match = opts.tagMatch ?? "all";
692
699
  if (match === "any") {
693
700
  // Flatten all expanded sets and dedupe — a note tagged with any one
@@ -710,9 +717,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
710
717
  }
711
718
  }
712
719
 
713
- // Exclude tags
720
+ // Exclude tags — bare-tag guard backstop (see tags block above).
714
721
  if (opts.excludeTags && opts.excludeTags.length > 0) {
715
- for (const tag of opts.excludeTags) {
722
+ for (const rawTag of opts.excludeTags) {
723
+ const tag = stripTagHash(rawTag);
724
+ if (tag === "") continue;
716
725
  conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
717
726
  params.push(tag);
718
727
  }
@@ -1189,11 +1198,18 @@ export function searchNotes(
1189
1198
  const limit = typeof opts?.limit === "number" ? opts.limit : 50;
1190
1199
 
1191
1200
  if (opts?.tags && opts.tags.length > 0) {
1201
+ // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
1202
+ const searchTags = opts.tags.map(stripTagHash).filter((t) => t !== "");
1203
+ if (searchTags.length === 0) {
1204
+ // All tag filters collapsed to empty — fall through to the untagged
1205
+ // search path below (no tag constraint).
1206
+ opts = { ...opts, tags: undefined };
1207
+ } else {
1192
1208
  try {
1193
1209
  // Tag membership as a semijoin — same rationale as queryNotes: a
1194
1210
  // `JOIN note_tags` multiplies rows for multi-tagged notes and forced
1195
1211
  // DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
1196
- const tagPlaceholders = opts.tags.map(() => "?").join(", ");
1212
+ const tagPlaceholders = searchTags.map(() => "?").join(", ");
1197
1213
  const rows = db.prepare(`
1198
1214
  SELECT n.* FROM notes n
1199
1215
  JOIN notes_fts fts ON fts.rowid = n.rowid
@@ -1201,11 +1217,12 @@ export function searchNotes(
1201
1217
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1202
1218
  ORDER BY rank
1203
1219
  LIMIT ?
1204
- `).all(query, ...opts.tags, limit) as NoteRow[];
1220
+ `).all(query, ...searchTags, limit) as NoteRow[];
1205
1221
  return notesWithTags(db, rows);
1206
1222
  } catch {
1207
1223
  return [];
1208
1224
  }
1225
+ }
1209
1226
  }
1210
1227
 
1211
1228
  try {
@@ -1236,7 +1253,15 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1236
1253
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1237
1254
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
1238
1255
 
1239
- for (const tag of tags) {
1256
+ // Canonical-bare-tag guard (vault#XXX): strip any leading `#` so the
1257
+ // `#`-decorated form a client may pass (the agent module stored
1258
+ // `#agent/message/inbound` verbatim) lands on the same bare row everyone
1259
+ // else queries. This is the single write chokepoint — createNote /
1260
+ // updateNote / batch / MCP add-tags / REST / import / transcript all funnel
1261
+ // through store.tagNote → here.
1262
+ for (const raw of tags) {
1263
+ const tag = stripTagHash(raw);
1264
+ if (tag === "") continue;
1240
1265
  insertTag.run(tag);
1241
1266
  insertNoteTag.run(noteId, tag);
1242
1267
  }
@@ -1244,7 +1269,10 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1244
1269
 
1245
1270
  export function untagNote(db: Database, noteId: string, tags: string[]): void {
1246
1271
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
1247
- for (const tag of tags) {
1272
+ // Mirror tagNote's normalization so removing `#tag` deletes the bare row.
1273
+ for (const raw of tags) {
1274
+ const tag = stripTagHash(raw);
1275
+ if (tag === "") continue;
1248
1276
  stmt.run(noteId, tag);
1249
1277
  }
1250
1278
  }
@@ -1359,6 +1387,11 @@ export type RenameTagResult =
1359
1387
  * after the cascade returns.
1360
1388
  */
1361
1389
  export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
1390
+ // Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
1391
+ // SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
1392
+ // hatch that lets the `#legacy/*` → `legacy/*` data migration find the
1393
+ // `#`-prefixed rows. (Renaming TO a `#`-name is the thing we're preventing.)
1394
+ newName = stripTagHash(newName);
1362
1395
  if (oldName === newName) {
1363
1396
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
1364
1397
  return exists
@@ -1727,6 +1760,9 @@ export function mergeTags(
1727
1760
  sources: string[],
1728
1761
  target: string,
1729
1762
  ): { merged: Record<string, number>; target: string } {
1763
+ // Normalize the TARGET so a merge can never create a `#`-prefixed tag. SOURCES
1764
+ // stay LITERAL so `#legacy/*` rows can be merged away (same carve-out as rename).
1765
+ target = stripTagHash(target);
1730
1766
  // Dedup + drop target-in-sources (self-merge is a no-op).
1731
1767
  const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
1732
1768
 
@@ -1991,15 +2027,18 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1991
2027
  export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
1992
2028
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1993
2029
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
2030
+ // Canonical-bare-tag guard (vault#XXX) — batchTag has its own SQL (does NOT
2031
+ // funnel through tagNote), so it strips leading `#` independently.
2032
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
1994
2033
  let count = 0;
1995
2034
 
1996
2035
  db.exec("BEGIN");
1997
2036
  try {
1998
- for (const tag of tags) {
2037
+ for (const tag of bareTags) {
1999
2038
  insertTag.run(tag);
2000
2039
  }
2001
2040
  for (const noteId of noteIds) {
2002
- for (const tag of tags) {
2041
+ for (const tag of bareTags) {
2003
2042
  insertNoteTag.run(noteId, tag);
2004
2043
  count++;
2005
2044
  }
@@ -2015,12 +2054,15 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
2015
2054
 
2016
2055
  export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
2017
2056
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
2057
+ // Mirror batchTag's bare-tag normalization so removing `#tag` deletes the
2058
+ // bare row.
2059
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
2018
2060
  let count = 0;
2019
2061
 
2020
2062
  db.exec("BEGIN");
2021
2063
  try {
2022
2064
  for (const noteId of noteIds) {
2023
- for (const tag of tags) {
2065
+ for (const tag of bareTags) {
2024
2066
  stmt.run(noteId, tag);
2025
2067
  count++;
2026
2068
  }
package/core/src/store.ts CHANGED
@@ -16,6 +16,7 @@ import { HookRegistry } from "./hooks.js";
16
16
  import {
17
17
  loadTagHierarchy,
18
18
  getTagExpansion,
19
+ stripTagHash,
19
20
  TAG_CONFIG_PREFIX,
20
21
  DEFAULT_TAG_NAME,
21
22
  DEFAULT_TAG_EXPAND_MODE,
@@ -251,8 +252,30 @@ export class BunSqliteStore implements Store {
251
252
  );
252
253
  }
253
254
 
255
+ /**
256
+ * Canonical-bare-tag guard (vault#XXX) for the QUERY path. Strip any leading
257
+ * `#` from `tags` / `excludeTags` BEFORE hierarchy expansion so a
258
+ * `#agent/message`-form query matches a bare-stored `agent/message` row (and
259
+ * vice-versa). This is what makes the data migration non-breaking — every
260
+ * old `#`-decorated query keeps working, mapping onto the same bare rows.
261
+ * Runs before `expandQueryTags` so hierarchy resolution / `_default` collapse
262
+ * see the bare names. Empty-after-strip entries are dropped.
263
+ */
264
+ private normalizeQueryTags(opts: QueryOpts): QueryOpts {
265
+ let next = opts;
266
+ if (opts.tags && opts.tags.length > 0) {
267
+ const tags = opts.tags.map(stripTagHash).filter((t) => t !== "");
268
+ next = { ...next, tags };
269
+ }
270
+ if (opts.excludeTags && opts.excludeTags.length > 0) {
271
+ const excludeTags = opts.excludeTags.map(stripTagHash).filter((t) => t !== "");
272
+ next = { ...next, excludeTags };
273
+ }
274
+ return next;
275
+ }
276
+
254
277
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
255
- return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
278
+ return noteOps.queryNotes(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
256
279
  }
257
280
 
258
281
  async queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage> {
@@ -262,7 +285,11 @@ export class BunSqliteStore implements Store {
262
285
  // descendant set → different rows match → caller should restart). The
263
286
  // alternative — hash the expanded set — would silently keep returning
264
287
  // stale results from a hierarchy snapshot the caller never saw.
265
- return noteOps.queryNotesPaged(this.db, this.expandQueryTags(opts));
288
+ //
289
+ // Bare-tag normalization runs first (before the hash is taken inside
290
+ // queryNotesPaged) so a `#tag`-form page-1 and a bare `tag`-form follow-up
291
+ // resolve to the same cursor query_hash.
292
+ return noteOps.queryNotesPaged(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
266
293
  }
267
294
 
268
295
  /**
@@ -332,6 +359,11 @@ export class BunSqliteStore implements Store {
332
359
  }
333
360
 
334
361
  async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
362
+ // Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
363
+ // filters before expansion, so `#manual` and `manual` resolve identically.
364
+ if (opts?.tags && opts.tags.length > 0) {
365
+ opts = { ...opts, tags: opts.tags.map(stripTagHash).filter((t) => t !== "") };
366
+ }
335
367
  // Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
336
368
  // (vault tag `expand` axis) — searching `#manual` should match notes
337
369
  // tagged with any descendant under "subtypes", any `manual/*` under
@@ -630,6 +662,18 @@ export class BunSqliteStore implements Store {
630
662
  parent_names?: string[] | null;
631
663
  },
632
664
  ) {
665
+ // Canonical-bare-tag guard (vault#XXX) for the SCHEMA path. Strip leading
666
+ // `#` from the tag NAME being upserted and from every `parent_names` entry,
667
+ // so the `tags` rows and the inheritance graph stay bare — matching the
668
+ // bare-stored note_tags. A `#foo` schema with `parent_names: ["#bar"]`
669
+ // becomes `foo` / `["bar"]`, so `tag:bar` still expands to `foo`.
670
+ tag = stripTagHash(tag);
671
+ if (patch.parent_names != null) {
672
+ patch = {
673
+ ...patch,
674
+ parent_names: patch.parent_names.map(stripTagHash).filter((p) => p !== ""),
675
+ };
676
+ }
633
677
  // Snapshot the prior indexed-field set BEFORE the write so the diff below
634
678
  // sees what this tag declared going in. Only needed when `fields` changes.
635
679
  const priorRecord =
@@ -27,6 +27,32 @@
27
27
 
28
28
  import { Database } from "bun:sqlite";
29
29
 
30
+ /**
31
+ * Canonical-bare-tag guard (vault#XXX). Strip any leading `#` (one or more)
32
+ * from a tag value so a `#`-prefixed tag can never be stored or queried
33
+ * out-of-band. Tags are stored BARE by convention (`agent/message/inbound`,
34
+ * not `#agent/message/inbound`); a client that passes the `#`-decorated form —
35
+ * as the agent module did, persisting `#agent/message/inbound` literally — must
36
+ * land on the same canonical row everyone else queries.
37
+ *
38
+ * Deliberately MINIMAL: this is NOT `normalizeTagValue` (portable-md.ts). It
39
+ * does NOT lowercase, slug-validate, or otherwise transform the value — casing
40
+ * and structure are preserved. The ONLY job is to remove the stray leading `#`.
41
+ *
42
+ * Idempotent: `#tag` / `##tag` / `tag` all collapse to `tag`. Only LEADING `#`
43
+ * is stripped — a `#` mid-string (e.g. `c#`) is left intact. Whitespace is
44
+ * trimmed first so a `" #tag"` from a sloppy client also normalizes. Applied at
45
+ * the canonical chokepoints (note-tag write, query/exclude filters, tag-schema
46
+ * name + parent_names) so MCP, REST, and direct-core callers all converge.
47
+ */
48
+ export function stripTagHash(tag: string): string {
49
+ // Strip any LEADING run of `#`/whitespace (handles `#tag`, `##tag`, ` #tag`,
50
+ // and `# tag` with a space after the hash), then trim the tail. A `#`
51
+ // mid-string (`c#`) is untouched; a degenerate `# #` collapses to "" (the
52
+ // write-path empty-tag gate drops it).
53
+ return tag.replace(/^[#\s]+/, "").trim();
54
+ }
55
+
30
56
  export interface TagHierarchy {
31
57
  /** tag → set of immediate child tags (those that declared `tag` as a parent). */
32
58
  childrenOf: Map<string, Set<string>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.13",
3
+ "version": "0.6.4-rc.14",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Canonical-bare-tag guard (vault#XXX) — REST layer. Confirms the PUT
3
+ * /tags/:name route normalizes the upserted tag NAME (so the partial-merge read
4
+ * + write land on the bare row) and leaves the rename SOURCE-name lookup literal
5
+ * (the migration escape hatch — a #-prefixed legacy tag must still be
6
+ * rename-able away). The query + note-tag write paths normalize in the store and
7
+ * are covered by core/src/bare-tag-guard.test.ts.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "bun:test";
11
+ import { Database } from "bun:sqlite";
12
+ import { SqliteStore } from "../core/src/store.ts";
13
+ import { initSchema } from "../core/src/schema.ts";
14
+ import { handleTags } from "./routes.ts";
15
+
16
+ let store: SqliteStore;
17
+ let db: Database;
18
+
19
+ beforeEach(() => {
20
+ db = new Database(":memory:");
21
+ initSchema(db);
22
+ store = new SqliteStore(db);
23
+ });
24
+
25
+ function put(name: string, body: unknown): Promise<Response> {
26
+ const req = new Request(`http://localhost/api/tags/${encodeURIComponent(name)}`, {
27
+ method: "PUT",
28
+ body: JSON.stringify(body),
29
+ });
30
+ return handleTags(req, store, `/${encodeURIComponent(name)}`);
31
+ }
32
+
33
+ describe("PUT /tags/:name — bare-tag guard", () => {
34
+ it("PUT /tags/%23foo with parent_names [#bar] stores the bare row + bare parents", async () => {
35
+ const res = await put("#foo", { parent_names: ["#bar"], description: "child" });
36
+ expect(res.status).toBe(200);
37
+
38
+ const record = await store.getTagRecord("foo");
39
+ expect(record).not.toBeNull();
40
+ expect(record!.parent_names).toEqual(["bar"]);
41
+ expect(await store.getTagRecord("#foo")).toBeNull();
42
+ });
43
+
44
+ it("a #-decorated PUT preserves the existing bare record's fields (merge correctness)", async () => {
45
+ await put("thing", { description: "first" });
46
+ await put("#thing", { parent_names: [] });
47
+ const record = await store.getTagRecord("thing");
48
+ expect(record!.description).toBe("first");
49
+ });
50
+ });
51
+
52
+ describe("POST /tags/:name/rename — source name stays literal", () => {
53
+ it("rename #agent/message → agent/message finds the literal #-prefixed row", async () => {
54
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
55
+ const note = await store.createNote("legacy inbound");
56
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
57
+
58
+ const sub = "/" + encodeURIComponent("#agent/message") + "/rename";
59
+ const req = new Request(`http://localhost/api/tags${sub}`, {
60
+ method: "POST",
61
+ body: JSON.stringify({ new_name: "agent/message" }),
62
+ });
63
+ const res = await handleTags(req, store, sub);
64
+ expect(res.status).toBe(200);
65
+ const body = (await res.json()) as { renamed?: number };
66
+ expect(body.renamed).toBeGreaterThan(0);
67
+
68
+ const tags = await store.listTags();
69
+ expect(tags.map((t) => t.name)).not.toContain("#agent/message");
70
+ expect(tags.map((t) => t.name)).toContain("agent/message");
71
+ });
72
+ });
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
- import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
+ import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
17
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
18
  import {
@@ -2086,7 +2086,15 @@ export async function handleTags(
2086
2086
  // of { description, fields, relationships, parent_names }; omitted keys
2087
2087
  // are preserved, explicit null clears. See patterns/tag-data-model.md.
2088
2088
  if (req.method === "PUT") {
2089
- if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
2089
+ // Canonical-bare-tag guard (vault#XXX): normalize the upserted tag NAME so
2090
+ // the existing-field merge read (store.getTagSchema below) and the upsert
2091
+ // both target the bare row. store.upsertTagRecord re-normalizes (idempotent)
2092
+ // for the write; this keeps the partial-merge read correct for a
2093
+ // `#`-decorated PUT path. (GET/DELETE/rename keep their literal lookups —
2094
+ // rename's source-name must still match a `#`-prefixed legacy row so the
2095
+ // data migration can rename it away.)
2096
+ const putTagName = stripTagHash(tagName);
2097
+ if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
2090
2098
  return tagScopeForbidden(tagScope.raw ?? []);
2091
2099
  }
2092
2100
  const body = (await req.json()) as {
@@ -2157,7 +2165,7 @@ export async function handleTags(
2157
2165
  const full = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
2158
2166
  fieldsPatch = Object.keys(full).length > 0 ? full : null;
2159
2167
  } else {
2160
- const existing = await store.getTagSchema(tagName);
2168
+ const existing = await store.getTagSchema(putTagName);
2161
2169
  const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
2162
2170
  ...(existing?.fields ?? {}),
2163
2171
  ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
@@ -2172,7 +2180,7 @@ export async function handleTags(
2172
2180
  // unchanged on failure (no orphan/lying index). vault#478.
2173
2181
  let result;
2174
2182
  try {
2175
- result = await store.upsertTagRecord(tagName, {
2183
+ result = await store.upsertTagRecord(putTagName, {
2176
2184
  ...(body.description !== undefined ? { description: body.description } : {}),
2177
2185
  ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
2178
2186
  ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),