@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5
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/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +190 -47
- package/core/src/notes.ts +107 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +231 -4
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +137 -8
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +350 -79
- package/src/routing.ts +29 -0
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +109 -0
- package/src/vault.test.ts +25 -8
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* flipped to real assertions in a later wave. See #552 for the full write-up.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, it,
|
|
12
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
14
|
import { SqliteStore } from "./store.js";
|
|
15
|
+
import { generateMcpTools } from "./mcp.js";
|
|
16
|
+
import { ParentCycleError } from "./tag-schemas.js";
|
|
17
|
+
import { declareField } from "./indexed-fields.js";
|
|
15
18
|
|
|
16
19
|
let store: SqliteStore;
|
|
17
20
|
let db: Database;
|
|
@@ -100,20 +103,193 @@ describe("contract: taxonomy — passing (lock in current behavior)", () => {
|
|
|
100
103
|
});
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
describe("contract: taxonomy —
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
106
|
+
describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
107
|
+
it("rename-tag is exposed as an MCP tool and cascades parent_names — the gardener's exact bug no longer happens", async () => {
|
|
108
|
+
// Root tag, a child that declares it as a parent (the parent_names
|
|
109
|
+
// surface the gardener's rename left orphaned), and a note tagged with
|
|
110
|
+
// the CHILD only (proves subtype expansion still finds it post-rename).
|
|
111
|
+
await store.upsertTagRecord("proj", { description: "root project tag" });
|
|
112
|
+
await store.upsertTagRecord("special", { parent_names: ["proj"] });
|
|
113
|
+
const childTagged = await store.createNote("filed under the child tag", { tags: ["special"] });
|
|
114
|
+
|
|
115
|
+
const tools = generateMcpTools(store);
|
|
116
|
+
const renameTag = tools.find((t) => t.name === "rename-tag");
|
|
117
|
+
expect(renameTag).toBeDefined();
|
|
118
|
+
expect(renameTag!.requiredVerb).toBe("write");
|
|
119
|
+
|
|
120
|
+
const result = await renameTag!.execute({ old_name: "proj", new_name: "initiative" }) as any;
|
|
121
|
+
expect("error" in result).toBe(false);
|
|
122
|
+
expect(result.parent_refs_updated).toBeGreaterThanOrEqual(1);
|
|
123
|
+
|
|
124
|
+
// The exact gardener's bug: "special"'s parent_names must follow the
|
|
125
|
+
// rename, not keep pointing at the now-dead "proj".
|
|
126
|
+
const special = await store.getTagRecord("special");
|
|
127
|
+
expect(special?.parent_names).toEqual(["initiative"]);
|
|
128
|
+
|
|
129
|
+
// The renamed-away tag is NOT a live query surface anymore.
|
|
130
|
+
const projRecord = await store.getTagRecord("proj");
|
|
131
|
+
expect(projRecord).toBeNull();
|
|
132
|
+
|
|
133
|
+
// The NEW tag doesn't silently miss child-tagged notes (the gardener's
|
|
134
|
+
// second symptom): subtype expansion of "initiative" still reaches the
|
|
135
|
+
// note tagged only with the child "special".
|
|
136
|
+
const viaExpansion = await store.queryNotes({ tags: ["initiative"] });
|
|
137
|
+
expect(viaExpansion.map((n) => n.id)).toContain(childTagged.id);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rename-tag MCP tool reports target_exists (not a silent overwrite) and tag_not_found for a missing source", async () => {
|
|
141
|
+
await store.upsertTagRecord("a", {});
|
|
142
|
+
await store.upsertTagRecord("b", {});
|
|
143
|
+
const tools = generateMcpTools(store);
|
|
144
|
+
const renameTag = tools.find((t) => t.name === "rename-tag")!;
|
|
145
|
+
|
|
146
|
+
let caught: any;
|
|
147
|
+
try {
|
|
148
|
+
await renameTag.execute({ old_name: "a", new_name: "b" });
|
|
149
|
+
} catch (e) { caught = e; }
|
|
150
|
+
expect(caught?.error_type).toBe("target_exists");
|
|
151
|
+
expect(caught?.conflicting).toContain("b");
|
|
152
|
+
|
|
153
|
+
caught = undefined;
|
|
154
|
+
try {
|
|
155
|
+
await renameTag.execute({ old_name: "nonexistent", new_name: "whatever" });
|
|
156
|
+
} catch (e) { caught = e; }
|
|
157
|
+
expect(caught?.error_type).toBe("tag_not_found");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("merge-tags is exposed as an MCP tool and merges N sources into one target", async () => {
|
|
161
|
+
await store.upsertTagRecord("draft", {});
|
|
162
|
+
await store.upsertTagRecord("wip", {});
|
|
163
|
+
const draftNote = await store.createNote("draft note", { tags: ["draft"] });
|
|
164
|
+
const wipNote = await store.createNote("wip note", { tags: ["wip"] });
|
|
165
|
+
|
|
166
|
+
const tools = generateMcpTools(store);
|
|
167
|
+
const mergeTags = tools.find((t) => t.name === "merge-tags");
|
|
168
|
+
expect(mergeTags).toBeDefined();
|
|
169
|
+
expect(mergeTags!.requiredVerb).toBe("write");
|
|
170
|
+
|
|
171
|
+
const result = await mergeTags!.execute({ sources: ["draft", "wip"], target: "active" }) as any;
|
|
172
|
+
expect(result.target).toBe("active");
|
|
173
|
+
expect(result.merged.draft).toBe(1);
|
|
174
|
+
expect(result.merged.wip).toBe(1);
|
|
175
|
+
|
|
176
|
+
const draftAfter = await store.getNote(draftNote.id);
|
|
177
|
+
const wipAfter = await store.getNote(wipNote.id);
|
|
178
|
+
expect(draftAfter!.tags).toEqual(["active"]);
|
|
179
|
+
expect(wipAfter!.tags).toEqual(["active"]);
|
|
180
|
+
expect(await store.getTagRecord("draft")).toBeNull();
|
|
181
|
+
expect(await store.getTagRecord("wip")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("delete-tag refuses a tag still referenced in another tag's parent_names, then cascade/detach both proceed and strip the reference", async () => {
|
|
185
|
+
await store.upsertTagRecord("proj", { description: "root" });
|
|
186
|
+
await store.upsertTagRecord("child", { parent_names: ["proj"] });
|
|
187
|
+
|
|
188
|
+
const tools = generateMcpTools(store);
|
|
189
|
+
const deleteTag = tools.find((t) => t.name === "delete-tag")!;
|
|
190
|
+
|
|
191
|
+
// Default (no flag): refused, nothing mutated.
|
|
192
|
+
const refused = await deleteTag.execute({ tag: "proj" }) as any;
|
|
193
|
+
expect(refused.error).toBe("tag_referenced_as_parent");
|
|
194
|
+
expect(refused.referencing_tags).toEqual(["child"]);
|
|
195
|
+
expect(await store.getTagRecord("proj")).not.toBeNull();
|
|
196
|
+
expect((await store.getTagRecord("child"))?.parent_names).toEqual(["proj"]);
|
|
197
|
+
|
|
198
|
+
// cascade: true proceeds and strips the stale reference.
|
|
199
|
+
const cascaded = await deleteTag.execute({ tag: "proj", cascade: true }) as any;
|
|
200
|
+
expect(cascaded.deleted).toBe(true);
|
|
201
|
+
expect(cascaded.parent_refs_detached).toBe(1);
|
|
202
|
+
expect(await store.getTagRecord("proj")).toBeNull();
|
|
203
|
+
expect((await store.getTagRecord("child"))?.parent_names ?? null).toBeFalsy();
|
|
204
|
+
|
|
205
|
+
// detach is a synonym — same repair, exercised on a second fixture.
|
|
206
|
+
await store.upsertTagRecord("proj2", { description: "root2" });
|
|
207
|
+
await store.upsertTagRecord("child2", { parent_names: ["proj2"] });
|
|
208
|
+
const detached = await deleteTag.execute({ tag: "proj2", detach: true }) as any;
|
|
209
|
+
expect(detached.deleted).toBe(true);
|
|
210
|
+
expect(detached.parent_refs_detached).toBe(1);
|
|
211
|
+
expect((await store.getTagRecord("child2"))?.parent_names ?? null).toBeFalsy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a parent_names cycle (direct A→B→A, and bare self-parent) is rejected at write time with error_type parent_cycle", async () => {
|
|
215
|
+
await store.upsertTagRecord("B", {});
|
|
216
|
+
await store.upsertTagRecord("A", { parent_names: ["B"] });
|
|
217
|
+
|
|
218
|
+
let caught: unknown;
|
|
219
|
+
try {
|
|
220
|
+
await store.upsertTagRecord("B", { parent_names: ["A"] });
|
|
221
|
+
} catch (e) { caught = e; }
|
|
222
|
+
expect(caught).toBeInstanceOf(ParentCycleError);
|
|
223
|
+
const cycleErr = caught as ParentCycleError;
|
|
224
|
+
expect(cycleErr.error_type).toBe("parent_cycle");
|
|
225
|
+
expect(cycleErr.tag).toBe("B");
|
|
226
|
+
expect(cycleErr.cycle).toContain("A");
|
|
227
|
+
expect(cycleErr.cycle).toContain("B");
|
|
228
|
+
// Nothing persisted — B's parent_names is untouched by the rejected write.
|
|
229
|
+
expect((await store.getTagRecord("B"))?.parent_names ?? null).toBeFalsy();
|
|
230
|
+
|
|
231
|
+
// Bare self-parent: also caught (getTagDescendants always includes the
|
|
232
|
+
// tag itself).
|
|
233
|
+
let selfCaught: unknown;
|
|
234
|
+
try {
|
|
235
|
+
await store.upsertTagRecord("C", { parent_names: ["C"] });
|
|
236
|
+
} catch (e) { selfCaught = e; }
|
|
237
|
+
expect(selfCaught).toBeInstanceOf(ParentCycleError);
|
|
238
|
+
expect((selfCaught as ParentCycleError).cycle).toEqual(["C", "C"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("doctor reports each finding type on a seeded-broken fixture, and stays clean on a healthy one", async () => {
|
|
242
|
+
// 1. dangling_parent_name — "ghost-parent" is never created as a tag row.
|
|
243
|
+
await store.upsertTagRecord("orphan-child", { parent_names: ["ghost-parent"] });
|
|
244
|
+
|
|
245
|
+
// 2. parent_names_cycle — the write-time guard blocks NEW cycles, so
|
|
246
|
+
// simulate pre-existing/pre-guard cyclic data with a direct DB write
|
|
247
|
+
// (same technique core.test.ts's cycle-tolerance tests use).
|
|
248
|
+
await store.upsertTagRecord("cyc-a", {});
|
|
249
|
+
await store.upsertTagRecord("cyc-b", { parent_names: ["cyc-a"] });
|
|
250
|
+
db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["cyc-b"]), "cyc-a");
|
|
251
|
+
|
|
252
|
+
// 3. mixed_type_indexed_field — "n" is indexed INTEGER, but this note's
|
|
253
|
+
// metadata.n is a JSON string. Not `strict`, so the write succeeds with
|
|
254
|
+
// just an advisory warning — landing exactly the mismatched value.
|
|
255
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
256
|
+
await store.createNote("bad metric", { tags: ["metric"], metadata: { n: "not-a-number" } });
|
|
257
|
+
|
|
258
|
+
// 4. orphaned_indexed_field_declarer — declare directly via the
|
|
259
|
+
// indexed-fields API, then drop the tag row WITHOUT releasing (the
|
|
260
|
+
// pre-fix gitcoin state prune-schema's own tests seed identically).
|
|
261
|
+
declareField(db, "legacy_status", "TEXT", "ghost-declarer");
|
|
262
|
+
|
|
263
|
+
// 5. dead_tag_metadata_reference (heuristic) — "epic" holds tag-shaped
|
|
264
|
+
// values elsewhere (a live tag), so a value that ISN'T a live tag is
|
|
265
|
+
// the drift signal.
|
|
266
|
+
await store.upsertTagRecord("launch-v2", {});
|
|
267
|
+
await store.createNote("current epic", { tags: ["launch-v2"], metadata: { epic: "launch-v2" } });
|
|
268
|
+
await store.createNote("stale epic reference", { tags: ["launch-v2"], metadata: { epic: "launch-v1" } });
|
|
269
|
+
|
|
270
|
+
const report = await store.doctor();
|
|
271
|
+
const byType = new Map(report.findings.map((f) => [f.type, f]));
|
|
272
|
+
|
|
273
|
+
expect(byType.get("dangling_parent_name")?.subject).toBe("orphan-child");
|
|
274
|
+
expect(byType.get("parent_names_cycle")).toBeDefined();
|
|
275
|
+
expect(byType.get("mixed_type_indexed_field")?.subject).toBe("n");
|
|
276
|
+
expect(byType.get("mixed_type_indexed_field")?.severity).toBe("error");
|
|
277
|
+
expect(byType.get("orphaned_indexed_field_declarer")?.subject).toBe("legacy_status");
|
|
278
|
+
const driftFinding = byType.get("dead_tag_metadata_reference");
|
|
279
|
+
expect(driftFinding?.subject).toBe("metadata.epic");
|
|
280
|
+
expect(driftFinding?.heuristic).toBe(true);
|
|
281
|
+
expect(driftFinding?.detail).toContain("launch-v1");
|
|
282
|
+
|
|
283
|
+
// Every finding type from the fixture is present — five distinct types.
|
|
284
|
+
expect(byType.size).toBe(5);
|
|
285
|
+
|
|
286
|
+
// A fresh, healthy vault reports clean.
|
|
287
|
+
const cleanDb = new Database(":memory:");
|
|
288
|
+
const cleanStore = new SqliteStore(cleanDb);
|
|
289
|
+
await cleanStore.upsertTagRecord("healthy", { description: "nothing wrong here" });
|
|
290
|
+
await cleanStore.createNote("fine", { tags: ["healthy"] });
|
|
291
|
+
const cleanReport = await cleanStore.doctor();
|
|
292
|
+
expect(cleanReport.findings).toEqual([]);
|
|
293
|
+
expect(cleanReport.summary).toContain("clean");
|
|
294
|
+
});
|
|
119
295
|
});
|
|
@@ -22,6 +22,7 @@ import { Database } from "bun:sqlite";
|
|
|
22
22
|
import { SqliteStore } from "./store.js";
|
|
23
23
|
import { generateMcpTools } from "./mcp.js";
|
|
24
24
|
import { TransitionConflictError } from "./notes.js";
|
|
25
|
+
import { TagFieldConflictError } from "./tag-schemas.js";
|
|
25
26
|
|
|
26
27
|
let store: SqliteStore;
|
|
27
28
|
let db: Database;
|
|
@@ -97,10 +98,98 @@ describe("contract: typed indexes — todo (#553)", () => {
|
|
|
97
98
|
test.todo(
|
|
98
99
|
`#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
|
|
99
100
|
);
|
|
100
|
-
test.todo(
|
|
101
|
-
`#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
|
|
102
|
-
);
|
|
103
101
|
test.todo(
|
|
104
102
|
`#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
|
|
105
103
|
);
|
|
106
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {
|
|
107
|
+
it("update-tag reports ALL invalid fields in one call (not just the first) and states explicitly that no changes were applied", async () => {
|
|
108
|
+
// Tag "a" declares two fields. Tag "b" then redeclares BOTH with
|
|
109
|
+
// conflicting specs — a NON-indexed type conflict on "x" AND an
|
|
110
|
+
// indexed-flag conflict on "y" — in the SAME update-tag call. Pre-#553
|
|
111
|
+
// the cross-tag validation loop threw on the first offending field
|
|
112
|
+
// ("x") and never even evaluated "y"; two testers independently assumed
|
|
113
|
+
// the whole call (including "y") had partially landed. (The
|
|
114
|
+
// BOTH-indexed type-conflict case is deliberately absent here — it
|
|
115
|
+
// keeps its pre-existing declareField → IndexedFieldError path; see
|
|
116
|
+
// `collectCrossTagFieldViolations`'s doc-comment exclusion 2 and the
|
|
117
|
+
// both-indexed test below.)
|
|
118
|
+
await store.upsertTagRecord("a", {
|
|
119
|
+
fields: {
|
|
120
|
+
x: { type: "string" },
|
|
121
|
+
y: { type: "boolean", indexed: true },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const tools = generateMcpTools(store);
|
|
126
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
127
|
+
|
|
128
|
+
let caught: unknown;
|
|
129
|
+
try {
|
|
130
|
+
await updateTag.execute({
|
|
131
|
+
tag: "b",
|
|
132
|
+
fields: {
|
|
133
|
+
x: { type: "integer" }, // non-indexed type_conflict vs tag "a"
|
|
134
|
+
y: { type: "boolean", indexed: false }, // indexed_flag_conflict vs tag "a"
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
caught = e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(caught).toBeInstanceOf(TagFieldConflictError);
|
|
142
|
+
const err = caught as TagFieldConflictError;
|
|
143
|
+
expect(err.violations).toHaveLength(2);
|
|
144
|
+
const byField = new Map(err.violations.map((v) => [v.field, v.reason]));
|
|
145
|
+
expect(byField.get("x")).toBe("type_conflict");
|
|
146
|
+
expect(byField.get("y")).toBe("indexed_flag_conflict");
|
|
147
|
+
// States explicitly that no changes were applied.
|
|
148
|
+
expect(err.message).toContain("no changes were applied");
|
|
149
|
+
// Structured conflicting-declarer field (scrubbed by the server layer
|
|
150
|
+
// for tag-scoped sessions; full detail here — core is scope-unaware).
|
|
151
|
+
expect(err.violations[0]!.other_tag).toBe("a");
|
|
152
|
+
|
|
153
|
+
// Nothing partially landed — tag "b" has no field declarations at all.
|
|
154
|
+
const bRecord = await store.getTagRecord("b");
|
|
155
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("a BOTH-indexed cross-tag type conflict keeps its pre-existing IndexedFieldError path (not TagFieldConflictError)", async () => {
|
|
159
|
+
// Wire-contract floor (vault#554): this exact case already errored on
|
|
160
|
+
// main via store.upsertTagRecord → declareField's cross-declarer
|
|
161
|
+
// sqlite-type check → IndexedFieldError (REST maps it to 400
|
|
162
|
+
// invalid_indexed_field). The new pre-check must not intercept it.
|
|
163
|
+
await store.upsertTagRecord("a", {
|
|
164
|
+
fields: { x: { type: "string", indexed: true } },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const tools = generateMcpTools(store);
|
|
168
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
169
|
+
|
|
170
|
+
let caught: any;
|
|
171
|
+
try {
|
|
172
|
+
await updateTag.execute({
|
|
173
|
+
tag: "b",
|
|
174
|
+
fields: { x: { type: "integer", indexed: true } },
|
|
175
|
+
});
|
|
176
|
+
} catch (e) {
|
|
177
|
+
caught = e;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(caught).toBeDefined();
|
|
181
|
+
expect(caught).not.toBeInstanceOf(TagFieldConflictError);
|
|
182
|
+
expect(caught.name).toBe("IndexedFieldError");
|
|
183
|
+
expect(caught.error_type).toBe("invalid_indexed_field");
|
|
184
|
+
// The structured declarer context the server's tag-scope scrub keys on.
|
|
185
|
+
expect(caught.field).toBe("x");
|
|
186
|
+
expect(caught.declarer_tags).toEqual(["a"]);
|
|
187
|
+
|
|
188
|
+
// The store's transaction rolled back — nothing persisted for "b".
|
|
189
|
+
const bRecord = await store.getTagRecord("b");
|
|
190
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
191
|
+
});
|
|
192
|
+
// REST `PUT /api/tags/:name` coverage for the same behavior lives in
|
|
193
|
+
// src/contract-errors.test.ts (core/ shouldn't import from src/ — that
|
|
194
|
+
// boundary runs one direction only).
|
|
195
|
+
});
|
package/core/src/core.test.ts
CHANGED
|
@@ -2030,6 +2030,12 @@ describe("MCP tools", async () => {
|
|
|
2030
2030
|
// prune-schema (admin) — drops orphaned indexed-field columns whose
|
|
2031
2031
|
// declaring tags are gone. The gitcoin orphaned-fields fix.
|
|
2032
2032
|
expect(names).toContain("prune-schema");
|
|
2033
|
+
// rename-tag / merge-tags (vault#552) — MCP parity with the
|
|
2034
|
+
// pre-existing REST rename/merge engine. doctor (admin, vault#552) —
|
|
2035
|
+
// read-only taxonomy/metadata integrity scan.
|
|
2036
|
+
expect(names).toContain("rename-tag");
|
|
2037
|
+
expect(names).toContain("merge-tags");
|
|
2038
|
+
expect(names).toContain("doctor");
|
|
2033
2039
|
// Six note-schema tools (list/update/delete-note-schema +
|
|
2034
2040
|
// list/set/delete-schema-mapping) retired in v17 — the standalone
|
|
2035
2041
|
// note_schemas + schema_mappings subsystem was a parallel path to
|
|
@@ -2043,7 +2049,7 @@ describe("MCP tools", async () => {
|
|
|
2043
2049
|
// synthesize-notes retired in v17 — replicable with query-notes(near=) +
|
|
2044
2050
|
// find-path + agent-side aggregation. See vault#268.
|
|
2045
2051
|
expect(names).not.toContain("synthesize-notes");
|
|
2046
|
-
expect(tools).toHaveLength(
|
|
2052
|
+
expect(tools).toHaveLength(13);
|
|
2047
2053
|
});
|
|
2048
2054
|
|
|
2049
2055
|
it("create-note tool works", async () => {
|
|
@@ -4170,7 +4176,17 @@ describe("tag hierarchy (tags.parent_names)", async () => {
|
|
|
4170
4176
|
|
|
4171
4177
|
it("tolerates a cycle without infinite-looping", async () => {
|
|
4172
4178
|
await store.upsertTagRecord("a", { parent_names: ["b"] });
|
|
4173
|
-
await store.upsertTagRecord("b", {
|
|
4179
|
+
await store.upsertTagRecord("b", {}); // bare row, no parent yet
|
|
4180
|
+
// vault#552: upsertTagRecord now REJECTS a parent_names write that would
|
|
4181
|
+
// close a cycle, so the second leg of the cycle can no longer be built
|
|
4182
|
+
// via the public API — simulate pre-existing cyclic data (written
|
|
4183
|
+
// before the guard shipped, or via any path that bypasses it) with a
|
|
4184
|
+
// direct DB write, same technique as "malformed parent_names JSON is
|
|
4185
|
+
// ignored silently" below. Traversal must stay cycle-safe regardless of
|
|
4186
|
+
// how the cycle got there.
|
|
4187
|
+
(store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
|
|
4188
|
+
.run(JSON.stringify(["a"]), "b");
|
|
4189
|
+
(store as any)._tagHierarchy = null;
|
|
4174
4190
|
await store.createNote("note-a", { tags: ["a"] });
|
|
4175
4191
|
|
|
4176
4192
|
// Both a and b should resolve without hanging; both reach the same set {a, b}.
|
|
@@ -4803,14 +4819,21 @@ describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
|
4803
4819
|
});
|
|
4804
4820
|
|
|
4805
4821
|
it("cycle: A→B, B→A — no infinite loop, both fields visible", async () => {
|
|
4822
|
+
await store.upsertTagRecord("B", {
|
|
4823
|
+
fields: { b_field: { type: "string" } },
|
|
4824
|
+
});
|
|
4806
4825
|
await store.upsertTagRecord("A", {
|
|
4807
4826
|
parent_names: ["B"],
|
|
4808
4827
|
fields: { a_field: { type: "string" } },
|
|
4809
4828
|
});
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4829
|
+
// vault#552: upsertTagRecord now REJECTS the write that would close this
|
|
4830
|
+
// cycle (B declaring A as parent, when A already declares B) — simulate
|
|
4831
|
+
// pre-existing cyclic data with a direct DB write, same technique as the
|
|
4832
|
+
// "tolerates a cycle without infinite-looping" test above.
|
|
4833
|
+
(store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
|
|
4834
|
+
.run(JSON.stringify(["A"]), "B");
|
|
4835
|
+
(store as any)._tagHierarchy = null;
|
|
4836
|
+
(store as any)._schemaConfig = null;
|
|
4814
4837
|
|
|
4815
4838
|
const tools = generateMcpTools(store);
|
|
4816
4839
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag-scope confidentiality for the `vault doctor` integrity scan (vault#552
|
|
3
|
+
* auth fold). Two finding types query notes VAULT-WIDE and previously leaked
|
|
4
|
+
* out-of-scope data to a tag-scoped caller:
|
|
5
|
+
*
|
|
6
|
+
* - `mixed_type_indexed_field`: the note query + mismatch count + exemplars
|
|
7
|
+
* + the "Declared by: <tags>" detail were all unscoped — a caller scoped
|
|
8
|
+
* to `mine` could see an out-of-scope declarer tag name AND an
|
|
9
|
+
* out-of-scope note id.
|
|
10
|
+
* - `dead_tag_metadata_reference`: the example live value (the `e.g.
|
|
11
|
+
* "<value>"` in detail) was computed vault-wide — leaking an out-of-scope
|
|
12
|
+
* tag NAME — and (unscoped) exemplars could include out-of-scope note ids.
|
|
13
|
+
*
|
|
14
|
+
* Each test asserts the out-of-scope tag name / note id appears NOWHERE in
|
|
15
|
+
* the serialized findings when scoped, and pairs with an UNSCOPED positive
|
|
16
|
+
* control proving the fixture genuinely carries that leak-able data (so the
|
|
17
|
+
* scoped assertion isn't vacuous — remove the scope filter and the control's
|
|
18
|
+
* leak reappears in the scoped run).
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
21
|
+
import { Database } from "bun:sqlite";
|
|
22
|
+
import { SqliteStore } from "./store.js";
|
|
23
|
+
import { runDoctorScan } from "./doctor.js";
|
|
24
|
+
|
|
25
|
+
const OUT_OF_SCOPE_TAG = "theirs-secret";
|
|
26
|
+
|
|
27
|
+
let store: SqliteStore;
|
|
28
|
+
let db: Database;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
db = new Database(":memory:");
|
|
32
|
+
store = new SqliteStore(db);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("doctor mixed_type_indexed_field × tag scope (vault#552 MUST-FIX 3)", () => {
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
// Field `n` is indexed INTEGER, co-declared by an in-scope tag (`mine`)
|
|
38
|
+
// and an out-of-scope one (`theirs-secret`). Both carry a note whose
|
|
39
|
+
// metadata.n is a JSON string (type mismatch), not strict so the write
|
|
40
|
+
// lands the poison value.
|
|
41
|
+
await store.upsertTagRecord("mine", { fields: { n: { type: "integer", indexed: true } } });
|
|
42
|
+
await store.upsertTagRecord(OUT_OF_SCOPE_TAG, { fields: { n: { type: "integer", indexed: true } } });
|
|
43
|
+
await store.createNote("in-scope mismatch", { id: "mine-mm", tags: ["mine"], metadata: { n: "bad-mine" } });
|
|
44
|
+
await store.createNote("out-of-scope mismatch", { id: "theirs-mm", tags: [OUT_OF_SCOPE_TAG], metadata: { n: "bad-theirs" } });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("scoped caller: the out-of-scope declarer name and out-of-scope note id appear NOWHERE", async () => {
|
|
48
|
+
const report = await store.doctor({ allowedTags: new Set(["mine"]) });
|
|
49
|
+
const finding = report.findings.find((f) => f.type === "mixed_type_indexed_field");
|
|
50
|
+
expect(finding).toBeDefined(); // the in-scope mismatch still fires it
|
|
51
|
+
expect(finding!.subject).toBe("n");
|
|
52
|
+
|
|
53
|
+
const serialized = JSON.stringify(report.findings);
|
|
54
|
+
expect(serialized).not.toContain(OUT_OF_SCOPE_TAG);
|
|
55
|
+
expect(serialized).not.toContain("theirs-mm");
|
|
56
|
+
// The in-scope exemplar IS present (proves the finding is real, not empty).
|
|
57
|
+
expect(serialized).toContain("mine-mm");
|
|
58
|
+
// Count reflects the single in-scope mismatch, not both.
|
|
59
|
+
expect(finding!.detail).toContain("1 note(s)");
|
|
60
|
+
expect(finding!.detail).toContain("Declared by: mine.");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("unscoped control: the same fixture DOES surface the out-of-scope declarer + note (mutation-check)", async () => {
|
|
64
|
+
const report = await runDoctorScan(db); // no allowedTags
|
|
65
|
+
const serialized = JSON.stringify(report.findings);
|
|
66
|
+
// Absent the scope filter, both the out-of-scope declarer name and the
|
|
67
|
+
// out-of-scope note id leak — this is exactly what the scoped run above
|
|
68
|
+
// must suppress. If the scope filter were removed, the scoped assertions
|
|
69
|
+
// would see these too and fail.
|
|
70
|
+
expect(serialized).toContain(OUT_OF_SCOPE_TAG);
|
|
71
|
+
expect(serialized).toContain("theirs-mm");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("doctor dead_tag_metadata_reference × tag scope (vault#552 MUST-FIX 4)", () => {
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
await store.upsertTagRecord("mine", {});
|
|
78
|
+
await store.upsertTagRecord(OUT_OF_SCOPE_TAG, {});
|
|
79
|
+
// Insertion ORDER matters: the out-of-scope live-tag value is created
|
|
80
|
+
// FIRST so the vault-wide `liveValues[0]` example would be
|
|
81
|
+
// "theirs-secret" absent the scope fix.
|
|
82
|
+
await store.createNote("oos live", { id: "theirs-live", tags: [OUT_OF_SCOPE_TAG], metadata: { epic: OUT_OF_SCOPE_TAG } });
|
|
83
|
+
await store.createNote("in-scope live", { id: "mine-live", tags: ["mine"], metadata: { epic: "mine" } });
|
|
84
|
+
await store.createNote("in-scope dead", { id: "mine-dead", tags: ["mine"], metadata: { epic: "ghost-epic" } });
|
|
85
|
+
await store.createNote("oos dead", { id: "theirs-dead", tags: [OUT_OF_SCOPE_TAG], metadata: { epic: "ghost-epic" } });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("scoped caller: the out-of-scope example tag name and out-of-scope note id appear NOWHERE", async () => {
|
|
89
|
+
const report = await store.doctor({ allowedTags: new Set(["mine"]) });
|
|
90
|
+
const finding = report.findings.find((f) => f.type === "dead_tag_metadata_reference");
|
|
91
|
+
expect(finding).toBeDefined();
|
|
92
|
+
expect(finding!.subject).toBe("metadata.epic");
|
|
93
|
+
expect(finding!.heuristic).toBe(true);
|
|
94
|
+
|
|
95
|
+
const serialized = JSON.stringify(report.findings);
|
|
96
|
+
// No out-of-scope tag NAME (as the `e.g. "<value>"` live example) and no
|
|
97
|
+
// out-of-scope note id (as an exemplar of the dead value).
|
|
98
|
+
expect(serialized).not.toContain(OUT_OF_SCOPE_TAG);
|
|
99
|
+
expect(serialized).not.toContain("theirs-dead");
|
|
100
|
+
expect(serialized).not.toContain("theirs-live");
|
|
101
|
+
// The in-scope live example + in-scope exemplar + dead value ARE present.
|
|
102
|
+
expect(finding!.detail).toContain('e.g. "mine"');
|
|
103
|
+
expect(serialized).toContain("mine-dead");
|
|
104
|
+
expect(serialized).toContain("ghost-epic");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("unscoped control: the same fixture leaks the out-of-scope example name + note id (mutation-check)", async () => {
|
|
108
|
+
const report = await runDoctorScan(db); // no allowedTags
|
|
109
|
+
const finding = report.findings.find((f) => f.type === "dead_tag_metadata_reference")!;
|
|
110
|
+
const serialized = JSON.stringify(report.findings);
|
|
111
|
+
// Vault-wide, the example live value is the first-inserted live tag
|
|
112
|
+
// ("theirs-secret"), and the out-of-scope note id is an exemplar — both
|
|
113
|
+
// the leaks the scoped run suppresses.
|
|
114
|
+
expect(finding.detail).toContain(`e.g. "${OUT_OF_SCOPE_TAG}"`);
|
|
115
|
+
expect(serialized).toContain("theirs-dead");
|
|
116
|
+
});
|
|
117
|
+
});
|