@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6

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.
@@ -42,8 +42,12 @@ function rawRow(id: string) {
42
42
  }
43
43
 
44
44
  describe("write-attribution — schema", () => {
45
- it("bumped SCHEMA_VERSION to 23", () => {
46
- expect(SCHEMA_VERSION).toBe(23);
45
+ it("bumped SCHEMA_VERSION to at least 23 (write-attribution columns landed)", () => {
46
+ // Was a literal `toBe(23)` pin — that goes stale every time a later PR
47
+ // bumps SCHEMA_VERSION further (vault#553 bumped it to 24). This test's
48
+ // actual claim is "the write-attribution migration landed at/after v23",
49
+ // not "v23 is still current."
50
+ expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(23);
47
51
  });
48
52
 
49
53
  it("the four columns exist and are indexed", () => {
@@ -230,7 +234,11 @@ describe("write-attribution — legacy backfill (v23 migration)", () => {
230
234
  const ver = (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as {
231
235
  v: number;
232
236
  }).v;
233
- expect(ver).toBe(23);
237
+ // A v22 vault runs the WHOLE migration chain (v23 write-attribution +
238
+ // whatever landed after — v24's typed-index poison scan, vault#553),
239
+ // ending at the current SCHEMA_VERSION rather than a hardcoded literal
240
+ // that goes stale on every later bump.
241
+ expect(ver).toBe(SCHEMA_VERSION);
234
242
 
235
243
  // Legacy row: attribution columns exist now but are NULL (not fabricated).
236
244
  const old = legacy
@@ -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, test, expect, beforeEach } from "bun:test";
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 — todo (#552)", () => {
104
- test.todo(
105
- "#552: deleting a tag still referenced in another tag's parent_names errors unless cascade/detach is passed (today: store.deleteTag succeeds unconditionally and leaves the referencing tag's parent_names pointing at a now-nonexistent tag name — verified with a fresh parent/child fixture)",
106
- );
107
- test.todo(
108
- "#552: a parent_names cycle (A declares parent B, then B declares parent A) is rejected at write time (today: store.upsertTagRecord accepts both writes with no cycle check — traversal elsewhere is cycle-safe, but the write itself is not honest about creating one)",
109
- );
110
- test.todo(
111
- "#552: rename-tag is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/:name/rename exists and store.renameTag is wired, but generateMcpTools has no rename-tag tool — an MCP-only agent cannot rename a tag)",
112
- );
113
- test.todo(
114
- "#552: merge-tags is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/merge exists and store.mergeTags is wired, but generateMcpTools has no merge-tags tool)",
115
- );
116
- test.todo(
117
- "#552: a `vault doctor` integrity scan reports dangling parent_names references (today: no doctor/scan surface inspects parent_names for referential integrity — the delete-tag gap above goes undetected)",
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
  });
@@ -20,9 +20,11 @@
20
20
  import { describe, it, test, expect, beforeEach } from "bun:test";
21
21
  import { Database } from "bun:sqlite";
22
22
  import { SqliteStore } from "./store.js";
23
- import { generateMcpTools } from "./mcp.js";
23
+ import { generateMcpTools, SchemaValidationError } from "./mcp.js";
24
24
  import { TransitionConflictError } from "./notes.js";
25
+ import * as noteOps from "./notes.js";
25
26
  import { TagFieldConflictError } from "./tag-schemas.js";
27
+ import { initSchema, SCHEMA_VERSION } from "./schema.js";
26
28
 
27
29
  let store: SqliteStore;
28
30
  let db: Database;
@@ -91,16 +93,231 @@ describe("contract: typed indexes — passing (lock in current behavior)", () =>
91
93
  });
92
94
  });
93
95
 
94
- describe("contract: typed indexes — todo (#553)", () => {
95
- test.todo(
96
- `#553: a write of a TEXT value to an indexed integer field is REJECTED (today: it is accepted with only an advisory type_mismatch warning, and the poisoned row then silently matches {gt: 100}-style range queries via SQLite's TEXT-sorts-above-INTEGER type-affinity ordering — reproduced live: metadata.n = "four" on an indexed integer field succeeds, and query {n: {gt: 100}} incorrectly returns it)`,
97
- );
98
- test.todo(
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)`,
100
- );
101
- test.todo(
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)`,
103
- );
96
+ describe("contract: typed indexes — Decision A: indexed ⇒ strict writes (#553, flipped from todo)", () => {
97
+ it("a write of a TEXT value to an indexed integer field is REJECTED, not just warned", async () => {
98
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
99
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
100
+
101
+ let err: any;
102
+ try {
103
+ await create.execute({ content: "x", tags: ["metric"], metadata: { n: "four" } });
104
+ } catch (e) {
105
+ err = e;
106
+ }
107
+ expect(err).toBeInstanceOf(SchemaValidationError);
108
+ expect(err.violations).toHaveLength(1);
109
+ expect(err.violations[0].field).toBe("n");
110
+ expect(err.violations[0].reason).toBe("type_mismatch");
111
+ expect(err.violations[0].strict).toBe(true);
112
+ // Message names field + expected type + got type.
113
+ expect(err.violations[0].message).toContain("n");
114
+ expect(err.violations[0].message).toContain("integer");
115
+ expect(err.violations[0].message).toContain("string");
116
+
117
+ // Nothing was written — the poisoned row never lands, so it can never
118
+ // sort above a real integer under {gt: 100}-style range queries.
119
+ const all = await store.queryNotes({ tags: ["metric"] });
120
+ expect(all).toHaveLength(0);
121
+ });
122
+
123
+ it("rejects the same indexed-type violation on update-note", async () => {
124
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
125
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
126
+ const update = generateMcpTools(store).find((t) => t.name === "update-note")!;
127
+ const note = (await create.execute({ content: "x", tags: ["metric"], metadata: { n: 5 } })) as any;
128
+
129
+ await expect(
130
+ update.execute({ id: note.id, metadata: { n: "four" }, if_updated_at: note.updatedAt }),
131
+ ).rejects.toThrow(SchemaValidationError);
132
+
133
+ // Untouched — still the original, well-typed value.
134
+ const fresh = await store.getNote(note.id);
135
+ expect((fresh!.metadata as any).n).toBe(5);
136
+ });
137
+
138
+ it("a range query never matches a TEXT value on an indexed integer field (the poisoning is now unreachable)", async () => {
139
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
140
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
141
+ await create.execute({ content: "n=5", tags: ["metric"], metadata: { n: 5 } });
142
+ await expect(
143
+ create.execute({ content: "poison", tags: ["metric"], metadata: { n: "four" } }),
144
+ ).rejects.toThrow(SchemaValidationError);
145
+
146
+ const gt100 = await store.queryNotes({ tags: ["metric"], metadata: { n: { gt: 100 } } });
147
+ expect(gt100).toHaveLength(0);
148
+ });
149
+
150
+ it("a type_mismatch on a NON-indexed field stays advisory (unchanged behavior — the escalation is scoped to indexed:true)", async () => {
151
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer" } } });
152
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
153
+ const note = (await create.execute({ content: "x", tags: ["metric"], metadata: { n: "four" } })) as any;
154
+ // Write succeeded — advisory only.
155
+ expect(note.metadata.n).toBe("four");
156
+ expect(note.validation_status.warnings[0].reason).toBe("type_mismatch");
157
+ expect(note.validation_status.warnings[0].strict).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("contract: typed indexes — Decision B: explicit-default-only enum backfill (#553, flipped from todo)", () => {
162
+ it("an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches", async () => {
163
+ await store.upsertTagRecord("task", {
164
+ fields: { status: { type: "string", enum: ["queued", "done"], indexed: true } }, // no `default`
165
+ });
166
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
167
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
168
+
169
+ const note = (await create.execute({ content: "x", tags: ["task"] })) as any;
170
+ expect(note.metadata?.status).toBeUndefined();
171
+ const onDisk = await store.getNote(note.id);
172
+ expect((onDisk!.metadata as any)?.status).toBeUndefined();
173
+
174
+ const unset = (await query.execute({
175
+ tag: "task",
176
+ metadata: { status: { exists: false } },
177
+ })) as any[];
178
+ expect(unset.map((n) => n.id)).toContain(note.id);
179
+
180
+ const set = (await query.execute({
181
+ tag: "task",
182
+ metadata: { status: { exists: true } },
183
+ })) as any[];
184
+ expect(set.map((n) => n.id)).not.toContain(note.id);
185
+ });
186
+
187
+ it("an EXPLICIT `default:` backfills as before — exists:true matches it", async () => {
188
+ await store.upsertTagRecord("task", {
189
+ fields: { status: { type: "string", enum: ["queued", "done"], default: "queued", indexed: true } },
190
+ });
191
+ const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
192
+ const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
193
+
194
+ const note = (await create.execute({ content: "x", tags: ["task"] })) as any;
195
+ expect(note.metadata.status).toBe("queued");
196
+
197
+ const set = (await query.execute({
198
+ tag: "task",
199
+ metadata: { status: { exists: true } },
200
+ })) as any[];
201
+ expect(set.map((n) => n.id)).toContain(note.id);
202
+ });
203
+
204
+ it("a non-conforming `default` is rejected as a tag-schema error, not silently stored", async () => {
205
+ const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
206
+ let err: any;
207
+ try {
208
+ await updateTag.execute({
209
+ tag: "task",
210
+ fields: { status: { type: "string", enum: ["queued", "done"], default: "bogus" } },
211
+ });
212
+ } catch (e) {
213
+ err = e;
214
+ }
215
+ expect(err).toBeInstanceOf(TagFieldConflictError);
216
+ expect(err.violations.some((v: any) => v.reason === "invalid_default" && v.field === "status")).toBe(true);
217
+ // Nothing persisted.
218
+ expect((await store.getTagRecord("task"))?.fields ?? null).toBeFalsy();
219
+ });
220
+ });
221
+
222
+ describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
223
+ it("the update-tag field-type description clarifies only string/integer/boolean are indexable", () => {
224
+ const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
225
+ const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
226
+ // Honest about the full storage/advisory vocabulary AND the indexable subset.
227
+ expect(typeDesc).toContain("number");
228
+ expect(typeDesc).toContain("Only string/integer/boolean are INDEXABLE");
229
+ });
230
+
231
+ it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
232
+ const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
233
+ let err: any;
234
+ try {
235
+ await updateTag.execute({ tag: "metric", fields: { score: { type: "number", indexed: true } } });
236
+ } catch (e) {
237
+ err = e;
238
+ }
239
+ expect(err).toBeInstanceOf(TagFieldConflictError);
240
+ expect(err.violations[0].reason).toBe("unsupported_indexed_type");
241
+ });
242
+
243
+ it("declaring a number field WITHOUT indexed:true is accepted (storage/advisory only)", async () => {
244
+ const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
245
+ const result = await updateTag.execute({ tag: "metric", fields: { score: { type: "number" } } }) as any;
246
+ expect(result.fields.score.type).toBe("number");
247
+ });
248
+ });
249
+
250
+ describe("contract: typed indexes — Decision D: migrateToV24 poison coercion (#553, new)", () => {
251
+ it("coerces a lossless numeric string, leaves a genuinely non-coercible string, and is idempotent on re-run", async () => {
252
+ // Declare the field indexed AFTER notes already exist (real-data-like:
253
+ // a field indexed retroactively on a vault with pre-existing rows).
254
+ const clean = noteOps.createNote(db, "clean", { metadata: { n: 42 } });
255
+ const coercible = noteOps.createNote(db, "coercible", { metadata: { n: "5" } }); // clean numeric string
256
+ const nonCoercible = noteOps.createNote(db, "non-coercible", { metadata: { n: "hello" } });
257
+ const boolish = noteOps.createNote(db, "boolish", { metadata: { flag: "true" } });
258
+
259
+ await store.upsertTagRecord("metric", {
260
+ fields: {
261
+ n: { type: "integer", indexed: true },
262
+ flag: { type: "boolean", indexed: true },
263
+ },
264
+ });
265
+ noteOps.tagNote(db, clean.id, ["metric"]);
266
+ noteOps.tagNote(db, coercible.id, ["metric"]);
267
+ noteOps.tagNote(db, nonCoercible.id, ["metric"]);
268
+ noteOps.tagNote(db, boolish.id, ["metric"]);
269
+
270
+ // Re-running initSchema (idempotent by construction for every migration
271
+ // step) exercises migrateToV24's coercion pass over the poison just
272
+ // planted via the raw noteOps layer (bypassing the strict write gate —
273
+ // simulating data that predates it, or an operator-side bulk import).
274
+ initSchema(db);
275
+
276
+ const after = (id: string) => (noteOps.getNote(db, id)!.metadata as any);
277
+ expect(after(clean.id).n).toBe(42); // untouched — was already clean
278
+ expect(after(coercible.id).n).toBe(5); // coerced: "5" (string) → 5 (number)
279
+ expect(after(nonCoercible.id).n).toBe("hello"); // LEFT IN PLACE — never deleted or nulled
280
+ expect(after(boolish.id).flag).toBe(true); // coerced: "true" (string) → true (boolean)
281
+
282
+ // Doctor still surfaces the genuinely non-coercible one for operator cleanup.
283
+ const report = await store.doctor();
284
+ const finding = report.findings.find(
285
+ (f) => f.type === "mixed_type_indexed_field" && f.subject === "n",
286
+ );
287
+ expect(finding).toBeDefined();
288
+ expect(finding!.detail).toContain(nonCoercible.id);
289
+
290
+ // Idempotency: a second re-run coerces nothing further and doesn't
291
+ // touch the already-clean/already-coerced/already-left values.
292
+ initSchema(db);
293
+ expect(after(clean.id).n).toBe(42);
294
+ expect(after(coercible.id).n).toBe(5);
295
+ expect(after(nonCoercible.id).n).toBe("hello");
296
+ expect(after(boolish.id).flag).toBe(true);
297
+ });
298
+
299
+ it("coerces a number into a TEXT-indexed field losslessly", async () => {
300
+ const note = noteOps.createNote(db, "x", { metadata: { code: 12345 } });
301
+ await store.upsertTagRecord("item", { fields: { code: { type: "string", indexed: true } } });
302
+ noteOps.tagNote(db, note.id, ["item"]);
303
+
304
+ initSchema(db);
305
+ expect((noteOps.getNote(db, note.id)!.metadata as any).code).toBe("12345");
306
+ });
307
+
308
+ it("leaves array/object values in an indexed field untouched (never coercible, never deleted)", async () => {
309
+ const note = noteOps.createNote(db, "x", { metadata: { n: [1, 2, 3] } });
310
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
311
+ noteOps.tagNote(db, note.id, ["metric"]);
312
+
313
+ initSchema(db);
314
+ expect((noteOps.getNote(db, note.id)!.metadata as any).n).toEqual([1, 2, 3]);
315
+ });
316
+
317
+ it("is a no-op on a vault with zero indexed fields (schema_version still advances)", async () => {
318
+ const row = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get() as { version: number };
319
+ expect(row.version).toBe(SCHEMA_VERSION);
320
+ });
104
321
  });
105
322
 
106
323
  describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {