@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7

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
@@ -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)", () => {
@@ -2081,8 +2081,11 @@ describe("MCP tools", async () => {
2081
2081
  // schema-default-filled field was missing from the returned note even
2082
2082
  // though it had just been written to disk.
2083
2083
  it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
2084
+ // vault#553 Decision B: backfill is explicit-`default`-only now — declare
2085
+ // one so this test still exercises the post-defaults re-read mechanism
2086
+ // (the thing vault#316 is actually about).
2084
2087
  await store.upsertTagSchema("task", {
2085
- fields: { priority: { type: "string", enum: ["high", "low"] } },
2088
+ fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
2086
2089
  });
2087
2090
  const tools = generateMcpTools(store);
2088
2091
  const createNote = tools.find((t) => t.name === "create-note")!;
@@ -2093,7 +2096,7 @@ describe("MCP tools", async () => {
2093
2096
  path: "Inbox/task-1",
2094
2097
  tags: ["task"],
2095
2098
  }) as any;
2096
- expect(single.metadata?.priority).toBe("high"); // first enum value
2099
+ expect(single.metadata?.priority).toBe("high"); // explicit schema default
2097
2100
  // Disk and response agree.
2098
2101
  const onDisk = await store.getNoteByPath("Inbox/task-1");
2099
2102
  expect((onDisk!.metadata as any)?.priority).toBe("high");
@@ -3456,7 +3459,7 @@ describe("MCP tools", async () => {
3456
3459
  expect(links.some((l) => l.relationship === "wikilink")).toBe(true);
3457
3460
  });
3458
3461
 
3459
- it("create-note with schema tag auto-populates defaults", async () => {
3462
+ it("create-note with schema tag leaves undeclared fields ABSENT (vault#553 Decision B — no implicit enum[0]/zero-value backfill)", async () => {
3460
3463
  await store.upsertTagSchema("person", {
3461
3464
  description: "A person",
3462
3465
  fields: {
@@ -3472,10 +3475,36 @@ describe("MCP tools", async () => {
3472
3475
 
3473
3476
  const result = await createNote.execute({ content: "Alice", tags: ["person"] }) as any;
3474
3477
  const fresh = await query.execute({ id: result.id }) as any;
3475
- expect(fresh.metadata.first_appeared).toBe("");
3476
- expect(fresh.metadata.active).toBe(false);
3477
- expect(fresh.metadata.priority).toBe(0);
3478
+ // None of these fields declares a `default` — the pre-0.7.0 behavior
3479
+ // (enum[0] / type zero-value backfill) is retired. "Never set" now stays
3480
+ // genuinely absent instead of masquerading as "explicitly set to X" — in
3481
+ // fact NO field got backfilled, so `metadata` itself stays empty/absent.
3482
+ expect(fresh.metadata?.first_appeared).toBeUndefined();
3483
+ expect(fresh.metadata?.active).toBeUndefined();
3484
+ expect(fresh.metadata?.priority).toBeUndefined();
3485
+ expect(fresh.metadata?.status).toBeUndefined();
3486
+ });
3487
+
3488
+ it("create-note with schema tag applies EXPLICIT `default:` values only (vault#553 Decision B)", async () => {
3489
+ await store.upsertTagSchema("employee", {
3490
+ description: "An employee",
3491
+ fields: {
3492
+ active: { type: "boolean", default: true },
3493
+ priority: { type: "integer", default: 3 },
3494
+ status: { type: "string", enum: ["active", "archived"], default: "active" },
3495
+ nickname: { type: "string" }, // no default — stays absent
3496
+ },
3497
+ });
3498
+ const tools = generateMcpTools(store);
3499
+ const createNote = tools.find((t) => t.name === "create-note")!;
3500
+ const query = tools.find((t) => t.name === "query-notes")!;
3501
+
3502
+ const result = await createNote.execute({ content: "Bob", tags: ["employee"] }) as any;
3503
+ const fresh = await query.execute({ id: result.id }) as any;
3504
+ expect(fresh.metadata.active).toBe(true);
3505
+ expect(fresh.metadata.priority).toBe(3);
3478
3506
  expect(fresh.metadata.status).toBe("active");
3507
+ expect(fresh.metadata.nickname).toBeUndefined();
3479
3508
  });
3480
3509
 
3481
3510
  // ---- query-notes input-shape tolerance (vault#214) ----
@@ -4616,8 +4645,9 @@ describe("update-note if_missing=create (vault#309)", async () => {
4616
4645
  });
4617
4646
 
4618
4647
  it("create branch applies tag-schema defaults when the new tag declares fields", async () => {
4648
+ // vault#553 Decision B: backfill is explicit-`default`-only.
4619
4649
  await store.upsertTagSchema("task", {
4620
- fields: { priority: { type: "string", enum: ["high", "low"] } },
4650
+ fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
4621
4651
  });
4622
4652
  const tools = generateMcpTools(store);
4623
4653
  const update = tools.find((t) => t.name === "update-note")!;
@@ -4629,7 +4659,7 @@ describe("update-note if_missing=create (vault#309)", async () => {
4629
4659
  }) as any;
4630
4660
  expect(result.created).toBe(true);
4631
4661
  // Schema defaults populated metadata.priority on insert.
4632
- expect(result.metadata?.priority).toBeDefined();
4662
+ expect(result.metadata?.priority).toBe("high");
4633
4663
  });
4634
4664
 
4635
4665
  it("create branch surfaces validation warnings just like create-note", async () => {
@@ -196,28 +196,58 @@ const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
196
196
  TEXT: new Set(["text"]),
197
197
  };
198
198
 
199
+ /** One vault-wide-scan result row from {@link findMixedTypeIndexedFieldNotes}. */
200
+ export interface MixedTypeIndexedFieldRow {
201
+ id: string;
202
+ /** SQLite `json_type()` of the CURRENT `metadata.<field>` value — "text", "integer", "real", "true", "false", "array", "object" (never "null": absent/null keys are excluded upstream). */
203
+ jt: string;
204
+ }
205
+
206
+ /**
207
+ * Vault-wide detector: every note whose `metadata.<field>` JSON type
208
+ * disagrees with `sqliteType` (the field's declared indexed storage class —
209
+ * "INTEGER" or "TEXT"). Returns `[]` for an unknown `sqliteType` or when
210
+ * nothing mismatches. UNSCOPED — no tag-scope filtering (that's the caller's
211
+ * job; see `scanMixedTypeIndexedFields` below for the tag-scoped doctor
212
+ * finding, and `migrateToV24` in `core/src/schema.ts` for the unscoped
213
+ * migration consumer). `json_type(metadata, ?)` returns NULL when the key is
214
+ * absent — those notes simply don't declare the field and aren't a mismatch.
215
+ *
216
+ * Extracted (vault#553 Decision D) so the SAME detection query backs both
217
+ * the `mixed_type_indexed_field` doctor finding AND the `migrateToV24`
218
+ * startup migration's poison scan — one source of truth for "what counts as
219
+ * mismatched," so a fix to one can't silently diverge from the other.
220
+ */
221
+ export function findMixedTypeIndexedFieldNotes(
222
+ db: Database,
223
+ field: string,
224
+ sqliteType: string,
225
+ ): MixedTypeIndexedFieldRow[] {
226
+ const expected = EXPECTED_JSON_TYPES[sqliteType];
227
+ if (!expected) return []; // unknown sqlite_type — nothing to compare against
228
+ const path = `$."${field}"`;
229
+ const rows = db
230
+ .prepare(
231
+ `SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
232
+ )
233
+ .all(path) as { id: string; jt: string | null }[];
234
+ return rows.filter((r): r is MixedTypeIndexedFieldRow => r.jt !== null && !expected.has(r.jt));
235
+ }
236
+
199
237
  function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
200
238
  const findings: DoctorFinding[] = [];
201
239
  const noteInScope = makeNoteInScope(db, allowedTags);
202
240
  for (const f of listIndexedFields(db)) {
203
241
  if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
204
- const expected = EXPECTED_JSON_TYPES[f.sqliteType];
205
- if (!expected) continue; // unknown sqlite_type — nothing to compare against
206
-
207
- // `json_type(metadata, ?)` returns NULL when the key is absent — those
208
- // notes simply don't declare the field and aren't a mismatch. Only rows
209
- // where the key IS present get compared.
210
- const path = `$."${f.field}"`;
211
- const rows = db
212
- .prepare(
213
- `SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
214
- )
215
- .all(path) as { id: string; jt: string | null }[];
242
+
243
+ const rows = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
244
+ if (rows.length === 0) continue;
245
+
216
246
  // Tag-scope (vault#552 auth fold): the note query above is vault-wide —
217
247
  // filter mismatches to IN-SCOPE notes so a scoped caller never sees an
218
248
  // out-of-scope note id (nor a count/exemplar reflecting one). If every
219
249
  // mismatch is on an out-of-scope note, the finding is dropped entirely.
220
- const mismatches = rows.filter((r) => r.jt !== null && !expected.has(r.jt) && noteInScope(r.id));
250
+ const mismatches = rows.filter((r) => noteInScope(r.id));
221
251
  if (mismatches.length === 0) continue;
222
252
 
223
253
  // Also generalize the "Declared by" list to in-scope declarers only —
package/core/src/mcp.ts CHANGED
@@ -5,7 +5,14 @@ import * as noteOps from "./notes.js";
5
5
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
6
6
  import { QueryError } from "./query-operators.js";
7
7
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
8
- import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
8
+ import {
9
+ collectUnknownTagWarnings,
10
+ emptySearchWarning,
11
+ ignoredParamWarning,
12
+ computeSearchDidYouMean,
13
+ searchDidYouMeanWarning,
14
+ type QueryWarning,
15
+ } from "./query-warnings.js";
9
16
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
10
17
  import * as linkOps from "./links.js";
11
18
  import * as tagSchemaOps from "./tag-schemas.js";
@@ -265,7 +272,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
265
272
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
266
273
  - Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
267
274
 
268
- \`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.`,
275
+ \`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
276
+
277
+ \`search\` indexes BOTH a note's title (\`path\`) and its \`content\` (vault#551 WS2C, schema v25) — a title match is weighted far above a passing body mention, so a dedicated note on a topic outranks another note that merely references it. Every result carries a \`score\` field (higher = more relevant; only meaningful as a RELATIVE comparison within one result set). Word matching also stems regular English affixes ("firefighter" matches "firefighters", "microbe" matches "microbes") — irregular plurals with a consonant change ("wolf"/"wolves") aren't covered by stemming. A search that returns ZERO results may carry a \`search_did_you_mean\` warning suggesting the closest indexed term when one looks like a likely typo (only unscoped sessions — tag-scoped tokens never see it, since the suggestion is computed vault-wide).`,
269
278
  inputSchema: {
270
279
  type: "object",
271
280
  properties: {
@@ -322,7 +331,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
322
331
  search: {
323
332
  type: "string",
324
333
  description:
325
- 'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
334
+ 'Full-text search query, matched against BOTH a note\'s title (path) and its content — a title match ranks far above a passing body mention. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking. Matching stems regular affixes ("firefighter"/"firefighters") but not irregular plurals ("wolf"/"wolves"). Results carry a `score` field (higher = more relevant, relative within this result set only). A zero-result search may carry a `search_did_you_mean` warning (unscoped sessions only).',
326
335
  },
327
336
  search_mode: {
328
337
  type: "string",
@@ -603,6 +612,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
603
612
  mode,
604
613
  sort: params.sort as "asc" | "desc" | undefined,
605
614
  });
615
+ // Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
616
+ // FTS5-vocabulary scan) and ONLY computed on the already-rare
617
+ // empty-result path, mirroring `unknown_tag`'s did_you_mean.
618
+ // Scope-unaware by construction (same as `collectUnknownTagWarnings`
619
+ // above) — safe here because `applyTagScopeWrappers`'s
620
+ // `query-notes` wrapper (`src/mcp-tools.ts`) strips the ENTIRE
621
+ // `warnings` array for a tag-scoped session before it reaches the
622
+ // caller, so this never leaks out-of-scope vocabulary to one.
623
+ if (results.length === 0) {
624
+ const suggestion = computeSearchDidYouMean(db, params.search as string);
625
+ if (suggestion) {
626
+ queryWarnings.push(searchDidYouMeanWarning(params.search as string, suggestion));
627
+ }
628
+ }
606
629
  }
607
630
  } else {
608
631
  // --- Structured query ---
@@ -1588,15 +1611,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1588
1611
  description: { type: "string", description: "Human-readable description of what this tag means" },
1589
1612
  fields: {
1590
1613
  type: "object",
1591
- description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
1614
+ description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true, "default": "active" } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299). Mark a field `indexed: true` to make it queryable — an indexed field\'s TYPE is ALWAYS enforced (a type-mismatched write is REJECTED, independent of `strict`) because a bad-typed value silently poisons range-query ordering (vault#553).',
1592
1615
  additionalProperties: {
1593
1616
  type: "object",
1594
1617
  properties: {
1595
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
1618
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1596
1619
  description: { type: "string" },
1597
- enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
1598
- indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
1599
- strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off." },
1620
+ enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
1621
+ default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
1622
+ indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean are indexable. Indexed a type-mismatched write is HARD-REJECTED (schema_validation), not just warned vault#553." },
1623
+ strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
1600
1624
  required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
1601
1625
  cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
1602
1626
  },
@@ -1953,18 +1977,33 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1953
1977
  * for any field they omitted. Returns the IDs of the notes whose metadata was
1954
1978
  * actually written — callers use this to re-read ONLY the mutated notes (and
1955
1979
  * to skip the re-read entirely when nothing changed). The common no-schema /
1956
- * no-defaults path returns an empty array.
1980
+ * no-declared-defaults path returns an empty array.
1981
+ *
1982
+ * vault#553 Decision B: backfill is EXPLICIT-`default`-only. A field with no
1983
+ * declared `default` is skipped entirely here — it stays genuinely absent on
1984
+ * the note, not silently filled with the first enum value or a type
1985
+ * zero-value (the pre-0.7.0 behavior, which made "never set" and
1986
+ * "explicitly set to the default" indistinguishable and broke `exists:false`).
1987
+ * Exported so `src/routes.ts` (REST create/PATCH) shares this ONE
1988
+ * implementation instead of carrying its own copy — the two had drifted into
1989
+ * a byte-identical duplicate pre-#553; centralizing here means the cloud
1990
+ * runtime (which imports core directly, not `src/routes.ts`) automatically
1991
+ * inherits this behavior with zero handler-side work.
1957
1992
  *
1958
1993
  * vault#299: this runs AFTER the create write (so AFTER the strict gate) and
1959
1994
  * intentionally does NOT re-run `enforceStrict`. Defaults are always
1960
- * conforming by construction — `defaultForField` returns the first enum value
1961
- * / the type's zero-value, so a default can never violate type/enum. And a
1962
- * `required` strict field is already caught at the pre-write gate, so a note
1963
- * that would need a default to satisfy `required` never reaches this filler
1964
- * (the create was rejected first). Don't add a defaults path that could
1965
- * inject a violating value without re-gating.
1995
+ * conforming by construction — `store.upsertTagRecord` validates a field's
1996
+ * `default` against its own `type`/`enum` BEFORE the schema can be persisted
1997
+ * (`InvalidFieldDefaultError` / `TagFieldConflictError` reason
1998
+ * `invalid_default`), so a default can never violate type/enum at read time
1999
+ * here. And a `required` strict field is already caught at the pre-write
2000
+ * gate, so a note that would need a default to satisfy `required` never
2001
+ * reaches this filler (the create was rejected first) — declaring a
2002
+ * `default` does NOT satisfy `required`; the caller must still set the field
2003
+ * explicitly. Don't add a defaults path that could inject a violating value
2004
+ * without re-gating.
1966
2005
  */
1967
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
2006
+ export async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1968
2007
  const schemas = tagSchemaOps.getTagSchemaMap(db);
1969
2008
  if (Object.keys(schemas).length === 0) return [];
1970
2009
 
@@ -1973,9 +2012,10 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1973
2012
  const schema = schemas[tag];
1974
2013
  if (!schema?.fields) continue;
1975
2014
  for (const [field, fieldSchema] of Object.entries(schema.fields)) {
1976
- if (!(field in defaults)) {
1977
- defaults[field] = defaultForField(fieldSchema);
1978
- }
2015
+ if (field in defaults) continue; // first tag that declares a REAL default wins
2016
+ const value = defaultForField(fieldSchema);
2017
+ if (value === undefined) continue; // no `default` declared — leave the slot open for a later tag
2018
+ defaults[field] = value;
1979
2019
  }
1980
2020
  }
1981
2021
  if (Object.keys(defaults).length === 0) return [];
@@ -2001,13 +2041,13 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
2001
2041
  return mutated;
2002
2042
  }
2003
2043
 
2004
- function defaultForField(field: { type: string; enum?: string[] }): unknown {
2005
- if (field.enum && field.enum.length > 0) return field.enum[0];
2006
- switch (field.type) {
2007
- case "boolean": return false;
2008
- case "integer": return 0;
2009
- default: return "";
2010
- }
2044
+ /**
2045
+ * Resolve a field's backfill value its declared `default` (vault#553
2046
+ * Decision B), or `undefined` when none was declared (the field stays
2047
+ * absent). Exported alongside `applySchemaDefaults` for `src/routes.ts`.
2048
+ */
2049
+ export function defaultForField(field: { default?: unknown }): unknown {
2050
+ return field.default;
2011
2051
  }
2012
2052
 
2013
2053
  // ---------------------------------------------------------------------------