@openparachute/vault 0.6.4-rc.9 → 0.6.4
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.
- package/README.md +6 -4
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +7 -2
- package/core/src/notes.ts +52 -10
- package/core/src/store.ts +65 -2
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +190 -25
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/README.md
CHANGED
|
@@ -943,7 +943,7 @@ cp .env.example .env # edit with your config
|
|
|
943
943
|
docker compose up -d
|
|
944
944
|
```
|
|
945
945
|
|
|
946
|
-
|
|
946
|
+
Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name: lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it the server boots with zero vaults — create one explicitly via the admin wizard, `parachute init --vault-name <name>`, or `parachute-vault create <name>`.
|
|
947
947
|
|
|
948
948
|
### Cloud platforms
|
|
949
949
|
|
|
@@ -968,9 +968,11 @@ typical v0.6 self-host wants. If you're not sure, use the hub-managed
|
|
|
968
968
|
path above. The standalone Blueprint stays in tree because some
|
|
969
969
|
operators specifically want this shape (vault#341).
|
|
970
970
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
971
|
+
Set `PARACHUTE_VAULT_NAME` to auto-create a first vault on startup (name:
|
|
972
|
+
lowercase alphanumeric + hyphens or underscores, 2–32 chars). Without it
|
|
973
|
+
the server boots with zero vaults — create one explicitly via the admin
|
|
974
|
+
wizard, `parachute init --vault-name <name>`, or `parachute-vault create
|
|
975
|
+
<name>`.
|
|
974
976
|
|
|
975
977
|
#### Other platforms
|
|
976
978
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance check (vault#283) — counting existing notes that would violate
|
|
3
|
+
* a proposed tightening of a tag's field spec. Backs the Schema editor's
|
|
4
|
+
* "N existing notes violate this" warning.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { SqliteStore } from "./store.js";
|
|
9
|
+
import { countConformanceViolations } from "./conformance.js";
|
|
10
|
+
|
|
11
|
+
let db: Database;
|
|
12
|
+
let store: SqliteStore;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
db = new Database(":memory:");
|
|
16
|
+
store = new SqliteStore(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("countConformanceViolations", () => {
|
|
20
|
+
it("returns zero violations when proposed fields is empty", async () => {
|
|
21
|
+
await store.createNote("n", { tags: ["task"], metadata: { status: "open" } });
|
|
22
|
+
const r = countConformanceViolations(db, "task", {});
|
|
23
|
+
expect(r.total_notes).toBe(1);
|
|
24
|
+
expect(r.violating_notes).toBe(0);
|
|
25
|
+
expect(r.checked_fields).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts notes missing a newly-required field", async () => {
|
|
29
|
+
await store.createNote("has", { tags: ["task"], metadata: { due: "2026-01-01" } });
|
|
30
|
+
await store.createNote("missing-1", { tags: ["task"], metadata: {} });
|
|
31
|
+
await store.createNote("missing-2", { tags: ["task"] });
|
|
32
|
+
|
|
33
|
+
const r = countConformanceViolations(db, "task", {
|
|
34
|
+
due: { type: "string", required: true },
|
|
35
|
+
});
|
|
36
|
+
expect(r.total_notes).toBe(3);
|
|
37
|
+
expect(r.violating_notes).toBe(2);
|
|
38
|
+
expect(r.checked_fields).toEqual(["due"]);
|
|
39
|
+
// missing_required reasons surface in the sample.
|
|
40
|
+
expect(r.sample.every((s) => s.fields.some((f) => f.reason === "missing_required"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts notes whose value falls outside a narrowed enum", async () => {
|
|
44
|
+
await store.createNote("ok", { tags: ["task"], metadata: { status: "open" } });
|
|
45
|
+
await store.createNote("bad", { tags: ["task"], metadata: { status: "archived" } });
|
|
46
|
+
|
|
47
|
+
const r = countConformanceViolations(db, "task", {
|
|
48
|
+
status: { type: "string", enum: ["open", "done"] },
|
|
49
|
+
});
|
|
50
|
+
expect(r.violating_notes).toBe(1);
|
|
51
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("enum_mismatch");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("counts notes whose value contradicts a changed type", async () => {
|
|
55
|
+
await store.createNote("num", { tags: ["task"], metadata: { count: 3 } });
|
|
56
|
+
await store.createNote("str", { tags: ["task"], metadata: { count: "three" } });
|
|
57
|
+
|
|
58
|
+
const r = countConformanceViolations(db, "task", {
|
|
59
|
+
count: { type: "integer" },
|
|
60
|
+
});
|
|
61
|
+
expect(r.violating_notes).toBe(1);
|
|
62
|
+
expect(r.sample[0]?.fields[0]?.reason).toBe("type_mismatch");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes descendant-tag notes via inheritance", async () => {
|
|
66
|
+
// dev/log declares dev as a parent → a strict field on dev applies to
|
|
67
|
+
// dev/log notes too.
|
|
68
|
+
await store.upsertTagRecord("dev/log", { parent_names: ["dev"] });
|
|
69
|
+
await store.createNote("parent-note", { tags: ["dev"], metadata: {} });
|
|
70
|
+
await store.createNote("child-note", { tags: ["dev/log"], metadata: {} });
|
|
71
|
+
|
|
72
|
+
const r = countConformanceViolations(db, "dev", {
|
|
73
|
+
kind: { type: "string", required: true },
|
|
74
|
+
});
|
|
75
|
+
expect(r.total_notes).toBe(2);
|
|
76
|
+
expect(r.violating_notes).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores fields the proposal does not touch", async () => {
|
|
80
|
+
await store.createNote("n", { tags: ["task"], metadata: { other: 123 } });
|
|
81
|
+
// We only propose `status`; `other` being weird is irrelevant.
|
|
82
|
+
const r = countConformanceViolations(db, "task", {
|
|
83
|
+
status: { type: "string" },
|
|
84
|
+
});
|
|
85
|
+
// `status` absent + not required → no violation.
|
|
86
|
+
expect(r.violating_notes).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("caps the sample at sampleLimit but counts all violations", async () => {
|
|
90
|
+
for (let i = 0; i < 5; i++) {
|
|
91
|
+
await store.createNote(`n${i}`, { tags: ["task"], metadata: {} });
|
|
92
|
+
}
|
|
93
|
+
const r = countConformanceViolations(
|
|
94
|
+
db,
|
|
95
|
+
"task",
|
|
96
|
+
{ req: { type: "string", required: true } },
|
|
97
|
+
{ sampleLimit: 2 },
|
|
98
|
+
);
|
|
99
|
+
expect(r.violating_notes).toBe(5);
|
|
100
|
+
expect(r.sample.length).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("store.countTagConformance", () => {
|
|
105
|
+
it("proxies to the core helper", async () => {
|
|
106
|
+
await store.createNote("n", { tags: ["task"], metadata: {} });
|
|
107
|
+
const r = await store.countTagConformance("task", {
|
|
108
|
+
due: { type: "string", required: true },
|
|
109
|
+
});
|
|
110
|
+
expect(r.violating_notes).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema conformance check — count how many EXISTING notes would violate a
|
|
3
|
+
* PROPOSED field spec for a tag, before the operator commits a tightening
|
|
4
|
+
* (vault#283, the MW2/#314 tie-in).
|
|
5
|
+
*
|
|
6
|
+
* Motivation: marking a field `strict`/`required`, narrowing an `enum`, or
|
|
7
|
+
* changing a `type` is a backwards-incompatible move — notes written under
|
|
8
|
+
* the old contract may now reject on their next write
|
|
9
|
+
* (`enforceStrictSchema` → `SchemaValidationError`). The Schema editor in
|
|
10
|
+
* the admin SPA calls this BEFORE save so the operator sees "N existing
|
|
11
|
+
* notes violate this — they'll reject on next write" and is pointed at the
|
|
12
|
+
* `parachute-vault schema migrate-field` CLI (#314) rather than silently
|
|
13
|
+
* shipping a contract their own data breaks.
|
|
14
|
+
*
|
|
15
|
+
* What "violate" means here: we overlay the PROPOSED `fields` for `tag` on
|
|
16
|
+
* top of the live schema config, treating every proposed field as if it
|
|
17
|
+
* were `strict:true` for the purpose of the count (so the count is "notes
|
|
18
|
+
* that would hard-reject IF this field were strict" — the worst case the
|
|
19
|
+
* operator is being warned about). We then walk every note carrying `tag`
|
|
20
|
+
* (descendants included, per the hierarchy) and run the SAME `validateNote`
|
|
21
|
+
* resolver the write path uses, counting notes with ≥1 violation on a field
|
|
22
|
+
* the proposal touches.
|
|
23
|
+
*
|
|
24
|
+
* This is a READ — no mutation. It's deliberately scoped to the proposed
|
|
25
|
+
* tag's own fields (the columns the operator is editing); inherited fields
|
|
26
|
+
* from ancestors are already enforced and aren't what the operator is
|
|
27
|
+
* tightening in this edit.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Database } from "bun:sqlite";
|
|
31
|
+
import {
|
|
32
|
+
loadSchemaConfig,
|
|
33
|
+
validateNote,
|
|
34
|
+
type SchemaField,
|
|
35
|
+
type ResolvedSchemas,
|
|
36
|
+
} from "./schema-defaults.ts";
|
|
37
|
+
import type { TagFieldSchema } from "./tag-schemas.ts";
|
|
38
|
+
import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
|
|
39
|
+
import { queryNotes, getNoteTagsForNotes } from "./notes.ts";
|
|
40
|
+
|
|
41
|
+
export interface ConformanceViolation {
|
|
42
|
+
/** Note id of a non-conforming note. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Note path, when set (helps the operator find it). */
|
|
45
|
+
path?: string | null;
|
|
46
|
+
/** The proposed-field violations on this note (reasons + messages). */
|
|
47
|
+
fields: { field: string; reason: string; message: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConformanceReport {
|
|
51
|
+
tag: string;
|
|
52
|
+
/** Total notes carrying the tag (descendants included). */
|
|
53
|
+
total_notes: number;
|
|
54
|
+
/** Count of notes that would violate the proposed spec. */
|
|
55
|
+
violating_notes: number;
|
|
56
|
+
/** The field names the proposal touches (the ones checked). */
|
|
57
|
+
checked_fields: string[];
|
|
58
|
+
/**
|
|
59
|
+
* A bounded sample of violating notes (id + path + which fields), so the
|
|
60
|
+
* SPA can show examples without paging the whole vault. Capped at
|
|
61
|
+
* `sampleLimit` (default 20).
|
|
62
|
+
*/
|
|
63
|
+
sample: ConformanceViolation[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert the on-disk `TagFieldSchema` shape to the resolver's `SchemaField`
|
|
68
|
+
* shape, FORCING `strict:true` so `validateNote` flags every constraint as a
|
|
69
|
+
* (strict) violation we can count. `indexed` is dropped — it's not a
|
|
70
|
+
* note-data constraint. `type` is narrowed to the resolver's union; an
|
|
71
|
+
* unknown type string is dropped (no type check) rather than throwing.
|
|
72
|
+
*/
|
|
73
|
+
function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
|
|
74
|
+
const out: SchemaField = { strict: true };
|
|
75
|
+
if (
|
|
76
|
+
spec.type === "string" ||
|
|
77
|
+
spec.type === "number" ||
|
|
78
|
+
spec.type === "integer" ||
|
|
79
|
+
spec.type === "boolean" ||
|
|
80
|
+
spec.type === "array" ||
|
|
81
|
+
spec.type === "object"
|
|
82
|
+
) {
|
|
83
|
+
out.type = spec.type;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(spec.enum)) out.enum = spec.enum;
|
|
86
|
+
if (spec.required === true) out.required = true;
|
|
87
|
+
if (spec.cardinality === "one" || spec.cardinality === "many") {
|
|
88
|
+
out.cardinality = spec.cardinality;
|
|
89
|
+
}
|
|
90
|
+
if (typeof spec.description === "string") out.description = spec.description;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a `ResolvedSchemas` snapshot with the proposed fields overlaid on
|
|
96
|
+
* `tag`'s OWN field map. We start from the live config (so inheritance +
|
|
97
|
+
* other tags are intact) and replace just `tag`'s field declarations with
|
|
98
|
+
* the strict-forced proposed set.
|
|
99
|
+
*/
|
|
100
|
+
function overlayProposedSchema(
|
|
101
|
+
base: ResolvedSchemas,
|
|
102
|
+
tag: string,
|
|
103
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
104
|
+
): ResolvedSchemas {
|
|
105
|
+
const tagToFields = new Map(base.tagToFields);
|
|
106
|
+
const forced: Record<string, SchemaField> = {};
|
|
107
|
+
for (const [name, spec] of Object.entries(proposedFields)) {
|
|
108
|
+
forced[name] = toStrictSchemaField(spec);
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(forced).length > 0) {
|
|
111
|
+
tagToFields.set(tag, forced);
|
|
112
|
+
} else {
|
|
113
|
+
tagToFields.delete(tag);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
allTags: new Set(base.allTags).add(tag),
|
|
117
|
+
tagToFields,
|
|
118
|
+
tagToParents: base.tagToParents,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Count existing notes that would violate the PROPOSED field spec for `tag`.
|
|
124
|
+
*
|
|
125
|
+
* `proposedFields` is the full merged field map the operator intends to save
|
|
126
|
+
* (the same object the PUT body carries). Only the fields in this map are
|
|
127
|
+
* checked — the violation count answers "if I tighten THESE fields, how many
|
|
128
|
+
* notes break?" The check runs against every note carrying `tag` or any of
|
|
129
|
+
* its descendants.
|
|
130
|
+
*
|
|
131
|
+
* Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
|
|
132
|
+
* empty (nothing to enforce).
|
|
133
|
+
*/
|
|
134
|
+
export function countConformanceViolations(
|
|
135
|
+
db: Database,
|
|
136
|
+
tag: string,
|
|
137
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
138
|
+
opts: { sampleLimit?: number } = {},
|
|
139
|
+
): ConformanceReport {
|
|
140
|
+
const sampleLimit = opts.sampleLimit ?? 20;
|
|
141
|
+
const checkedFields = Object.keys(proposedFields);
|
|
142
|
+
|
|
143
|
+
const empty: ConformanceReport = {
|
|
144
|
+
tag,
|
|
145
|
+
total_notes: 0,
|
|
146
|
+
violating_notes: 0,
|
|
147
|
+
checked_fields: checkedFields,
|
|
148
|
+
sample: [],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Resolve the tag's descendant set so we count notes on subtype tags too
|
|
152
|
+
// (a strict field on `#dev` applies to `#dev/log` notes via inheritance).
|
|
153
|
+
const hierarchy = loadTagHierarchy(db);
|
|
154
|
+
const tagSet = Array.from(getTagDescendants(hierarchy, tag));
|
|
155
|
+
if (tagSet.length === 0) return empty;
|
|
156
|
+
|
|
157
|
+
// All notes carrying the tag (or a descendant). `tagMatch: "any"` over the
|
|
158
|
+
// expanded set — a note on ANY of these tags is in scope. We hydrate tags
|
|
159
|
+
// ourselves (batched) so the resolver sees the full ancestor set per note.
|
|
160
|
+
const notes = queryNotes(db, { tags: tagSet, tagMatch: "any" });
|
|
161
|
+
if (notes.length === 0) return empty;
|
|
162
|
+
|
|
163
|
+
if (checkedFields.length === 0) {
|
|
164
|
+
return { ...empty, total_notes: notes.length };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const base = loadSchemaConfig(db);
|
|
168
|
+
const resolved = overlayProposedSchema(base, tag, proposedFields);
|
|
169
|
+
const checkedSet = new Set(checkedFields);
|
|
170
|
+
|
|
171
|
+
const tagsById = getNoteTagsForNotes(
|
|
172
|
+
db,
|
|
173
|
+
notes.map((n) => n.id),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let violating = 0;
|
|
177
|
+
const sample: ConformanceViolation[] = [];
|
|
178
|
+
|
|
179
|
+
for (const note of notes) {
|
|
180
|
+
const tags = tagsById.get(note.id) ?? note.tags ?? [];
|
|
181
|
+
const status = validateNote(resolved, {
|
|
182
|
+
path: note.path ?? null,
|
|
183
|
+
tags,
|
|
184
|
+
metadata: note.metadata ?? {},
|
|
185
|
+
});
|
|
186
|
+
if (!status) continue;
|
|
187
|
+
// Keep only violations on a field the PROPOSAL touches (ignore conflicts
|
|
188
|
+
// + violations on inherited/other fields the operator isn't editing).
|
|
189
|
+
const relevant = status.warnings.filter(
|
|
190
|
+
(w) => w.reason !== "schema_conflict" && checkedSet.has(w.field),
|
|
191
|
+
);
|
|
192
|
+
if (relevant.length === 0) continue;
|
|
193
|
+
violating++;
|
|
194
|
+
if (sample.length < sampleLimit) {
|
|
195
|
+
sample.push({
|
|
196
|
+
id: note.id,
|
|
197
|
+
path: note.path ?? null,
|
|
198
|
+
fields: relevant.map((w) => ({
|
|
199
|
+
field: w.field,
|
|
200
|
+
reason: w.reason,
|
|
201
|
+
message: w.message,
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
tag,
|
|
209
|
+
total_notes: notes.length,
|
|
210
|
+
violating_notes: violating,
|
|
211
|
+
checked_fields: checkedFields,
|
|
212
|
+
sample,
|
|
213
|
+
};
|
|
214
|
+
}
|
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
|
-
|
|
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
|