@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.
@@ -316,6 +316,111 @@ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)",
316
316
  });
317
317
  });
318
318
 
319
+ describe("contract: search — recall + ranking legibility (WS2B/C, #551, schema v25)", () => {
320
+ it("a note's TITLE (path) is now searchable — a term appearing ONLY in path, not content", async () => {
321
+ await store.createNote("nothing in this body matches the query term", {
322
+ path: "quarterly-budget-review",
323
+ });
324
+ const res = await search("search=budget&include_content=true");
325
+ expect(res.status).toBe(200);
326
+ const body = await bodyOf(res);
327
+ expect(body.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
328
+ });
329
+
330
+ it("a title match outranks a passing body-only mention — legible via the score field", async () => {
331
+ await store.createNote("only a passing mention of espresso here, nothing more", {
332
+ path: "coffee-notes",
333
+ });
334
+ await store.createNote("everything about espresso — brewing, grinding, tasting", {
335
+ path: "espresso",
336
+ });
337
+ const res = await search("search=espresso&include_content=true");
338
+ expect(res.status).toBe(200);
339
+ const body = await bodyOf(res);
340
+ const titleMatchIdx = body.findIndex((n: any) => n.path === "espresso");
341
+ const bodyMatchIdx = body.findIndex((n: any) => n.path === "coffee-notes");
342
+ expect(titleMatchIdx).toBeGreaterThanOrEqual(0);
343
+ expect(bodyMatchIdx).toBeGreaterThanOrEqual(0);
344
+ expect(titleMatchIdx).toBeLessThan(bodyMatchIdx);
345
+ for (const n of body) expect(typeof n.score).toBe("number");
346
+ const scoreOf = (path: string) => body.find((n: any) => n.path === path).score;
347
+ expect(scoreOf("espresso")).toBeGreaterThan(scoreOf("coffee-notes"));
348
+ });
349
+
350
+ it("score is present on the LEAN (default, no include_content) shape too", async () => {
351
+ await store.createNote(NOTES.bothWords);
352
+ const res = await search("search=widgets");
353
+ expect(res.status).toBe(200);
354
+ const body = await bodyOf(res);
355
+ expect(body.length).toBeGreaterThan(0);
356
+ for (const n of body) expect(typeof n.score).toBe("number");
357
+ });
358
+
359
+ it("porter stemming: singular query matches a plural-only body word", async () => {
360
+ await store.createNote("the firefighters responded quickly to the call");
361
+ const res = await search("search=firefighter&include_content=true");
362
+ expect(res.status).toBe(200);
363
+ const body = await bodyOf(res);
364
+ expect(body.some((n: any) => n.content.includes("firefighters"))).toBe(true);
365
+ });
366
+
367
+ it("a zero-result search with a nearby indexed term surfaces a search_did_you_mean warning", async () => {
368
+ await store.createNote("a note that discusses Vasquez and the incident report");
369
+ const res = await search("search=Vasqez");
370
+ expect(res.status).toBe(200);
371
+ const body = await bodyOf(res);
372
+ expect(body).toEqual([]);
373
+ const warnings = decodeWarningsHeader(res);
374
+ expect(warnings).not.toBeNull();
375
+ const w = warnings!.find((x: any) => x.code === "search_did_you_mean");
376
+ expect(w).toBeDefined();
377
+ expect(w.did_you_mean).toBe("vasquez");
378
+ });
379
+
380
+ it("a genuinely zero-result search with NO close vocabulary match carries no did_you_mean warning", async () => {
381
+ await store.createNote(NOTES.bothWords);
382
+ const res = await search("search=zzzznonexistentword");
383
+ expect(res.status).toBe(200);
384
+ const body = await bodyOf(res);
385
+ expect(body).toEqual([]);
386
+ const warnings = decodeWarningsHeader(res);
387
+ if (warnings) {
388
+ expect(warnings.some((w: any) => w.code === "search_did_you_mean")).toBe(false);
389
+ }
390
+ });
391
+
392
+ it("did_you_mean is suppressed for a tag-scoped session (no leak of vault-wide vocabulary)", async () => {
393
+ // Out-of-scope note carries the near-match term; the scoped token's
394
+ // own allowlist ("health") sees nothing else. The did_you_mean
395
+ // suggestion is computed against the WHOLE vault's FTS5 vocabulary
396
+ // regardless of scope, so a scoped caller must never see it — same
397
+ // "no leak" stance as `unknown_tag`'s did_you_mean.
398
+ await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
399
+ const scopedReq = new Request(`${BASE}/notes?search=Vasqez`, { method: "GET" });
400
+ const res = await handleNotes(scopedReq, store, "", undefined, {
401
+ allowed: new Set(["health"]),
402
+ raw: ["health"],
403
+ });
404
+ expect(res.status).toBe(200);
405
+ const body = await bodyOf(res);
406
+ expect(body).toEqual([]);
407
+ const warnings = decodeWarningsHeader(res);
408
+ expect(warnings?.some((w: any) => w.code === "search_did_you_mean")).toBeFalsy();
409
+ });
410
+
411
+ it('search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
412
+ await store.createNote("espresso and coffee content");
413
+ const res = await search(`search=${encodeURIComponent("-espresso")}&search_mode=advanced`);
414
+ expect(res.status).toBe(400);
415
+ const body = await bodyOf(res);
416
+ expect(body.code).toBe("INVALID_QUERY");
417
+ expect(body.error_type).toBe("invalid_search_syntax");
418
+ expect(typeof body.hint).toBe("string");
419
+ expect(body.hint).toMatch(/BINARY operator/i);
420
+ expect(body.hint).not.toMatch(/^no such column/i);
421
+ });
422
+ });
423
+
319
424
  describe("contract: search — MCP parity (#551)", () => {
320
425
  it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
321
426
  const tools = generateMcpTools(store);
@@ -356,4 +461,67 @@ describe("contract: search — MCP parity (#551)", () => {
356
461
  const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
357
462
  expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
358
463
  });
464
+
465
+ it("query-notes: title match outranks a body-only mention, and results carry a score (parity with REST)", async () => {
466
+ await store.createNote("only a passing mention of espresso here, nothing more", { path: "coffee-notes" });
467
+ await store.createNote("everything about espresso — brewing, grinding, tasting", { path: "espresso" });
468
+ const tools = generateMcpTools(store);
469
+ const query = tools.find((t) => t.name === "query-notes")!;
470
+ const result = (await query.execute({ search: "espresso" })) as any[];
471
+ const titleIdx = result.findIndex((n: any) => n.path === "espresso");
472
+ const bodyIdx = result.findIndex((n: any) => n.path === "coffee-notes");
473
+ expect(titleIdx).toBeGreaterThanOrEqual(0);
474
+ expect(bodyIdx).toBeGreaterThanOrEqual(0);
475
+ expect(titleIdx).toBeLessThan(bodyIdx);
476
+ for (const n of result) expect(typeof n.score).toBe("number");
477
+ });
478
+
479
+ it("query-notes: a note's title (path) is searchable (parity with REST)", async () => {
480
+ await store.createNote("nothing in this body matches the query term", { path: "quarterly-budget-review" });
481
+ const tools = generateMcpTools(store);
482
+ const query = tools.find((t) => t.name === "query-notes")!;
483
+ const result = (await query.execute({ search: "budget" })) as any[];
484
+ expect(result.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
485
+ });
486
+
487
+ it("query-notes: porter stemming matches a plural-only body word (parity with REST)", async () => {
488
+ await store.createNote("the firefighters responded quickly to the call");
489
+ const tools = generateMcpTools(store);
490
+ const query = tools.find((t) => t.name === "query-notes")!;
491
+ const result = (await query.execute({ search: "firefighter", include_content: true })) as any[];
492
+ expect(result.some((n: any) => n.content.includes("firefighters"))).toBe(true);
493
+ });
494
+
495
+ it("query-notes: zero-result search with a nearby indexed term surfaces search_did_you_mean", async () => {
496
+ await store.createNote("a note that discusses Vasquez and the incident report");
497
+ const tools = generateMcpTools(store);
498
+ const query = tools.find((t) => t.name === "query-notes")!;
499
+ const result = (await query.execute({ search: "Vasqez" })) as any;
500
+ // Zero notes + a non-empty warnings array — per the response-shape
501
+ // contract (vault#550) that's the `{notes, warnings}` envelope, not a
502
+ // bare array (a bare array can't carry `warnings` at all).
503
+ expect(Array.isArray(result)).toBe(false);
504
+ expect(result.notes).toEqual([]);
505
+ expect(result.warnings).toBeDefined();
506
+ const w = result.warnings.find((x: any) => x.code === "search_did_you_mean");
507
+ expect(w).toBeDefined();
508
+ expect(w.did_you_mean).toBe("vasquez");
509
+ });
510
+
511
+ it('query-notes: search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
512
+ await store.createNote("espresso and coffee content");
513
+ const tools = generateMcpTools(store);
514
+ const query = tools.find((t) => t.name === "query-notes")!;
515
+ let err: any;
516
+ try {
517
+ await query.execute({ search: "-espresso", search_mode: "advanced" });
518
+ } catch (e) {
519
+ err = e;
520
+ }
521
+ expect(err).toBeDefined();
522
+ expect(err.error_type).toBe("invalid_search_syntax");
523
+ expect(typeof err.hint).toBe("string");
524
+ expect(err.hint).toMatch(/BINARY operator/i);
525
+ expect(err.hint).not.toMatch(/^no such column/i);
526
+ });
359
527
  });
@@ -134,3 +134,56 @@ describe("MCP query-notes search × tag-scope — combined enforcement (vault#55
134
134
  }
135
135
  });
136
136
  });
137
+
138
+ /**
139
+ * NIT 1 (auth-561 review, #565): did_you_mean on a zero-result search is
140
+ * computed against the WHOLE vault's FTS5 vocabulary regardless of scope
141
+ * (`computeSearchDidYouMean` is scope-unaware by architecture, like every
142
+ * warning core produces). The MCP query-notes wrapper
143
+ * (`applyTagScopeWrappers`, src/mcp-tools.ts) strips the ENTIRE `warnings`
144
+ * array for a tag-scoped session, which covers `search_did_you_mean`
145
+ * generically — but the security property (a scoped token must not learn an
146
+ * out-of-scope note's vocabulary via a spelling suggestion) deserves a
147
+ * direct test, mirroring the REST test in src/contract-search.test.ts.
148
+ */
149
+ function warningsOf(result: unknown): any[] {
150
+ if (result && typeof result === "object" && "warnings" in result && Array.isArray((result as any).warnings)) {
151
+ return (result as any).warnings;
152
+ }
153
+ return [];
154
+ }
155
+
156
+ describe("MCP query-notes did_you_mean × tag-scope suppression (#565 NIT 1)", () => {
157
+ test("a scoped session's zero-result search does NOT surface an out-of-scope term via search_did_you_mean", async () => {
158
+ seedVault("journal");
159
+ const store = getVaultStore("journal");
160
+
161
+ // The near-match term "Vasquez" lives ONLY in an out-of-scope note.
162
+ await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
163
+
164
+ const scopedTool = await queryNotesTool("journal", ["health"]);
165
+ const scopedResult = await scopedTool.execute({ search: "Vasqez" }); // typo → zero results
166
+ expect(idsOf(scopedResult)).toEqual([]);
167
+ // The wrapper strips warnings wholesale for a scoped session — no
168
+ // search_did_you_mean leaking the out-of-scope "vasquez".
169
+ expect(warningsOf(scopedResult).some((w: any) => w.code === "search_did_you_mean")).toBe(false);
170
+ // Belt-and-suspenders: the out-of-scope term must not appear ANYWHERE in
171
+ // the serialized response (no did_you_mean field slipping through under a
172
+ // different shape).
173
+ expect(JSON.stringify(scopedResult).toLowerCase()).not.toContain("vasquez");
174
+ });
175
+
176
+ test("control: an UNSCOPED session's zero-result search DOES surface the suggestion (proves it's suppressed by scope, not simply absent)", async () => {
177
+ seedVault("journal");
178
+ const store = getVaultStore("journal");
179
+
180
+ await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
181
+
182
+ const unscopedTool = await queryNotesTool("journal", null);
183
+ const result = await unscopedTool.execute({ search: "Vasqez" });
184
+ expect(idsOf(result)).toEqual([]);
185
+ const w = warningsOf(result).find((x: any) => x.code === "search_did_you_mean");
186
+ expect(w).toBeDefined();
187
+ expect(w.did_you_mean).toBe("vasquez");
188
+ });
189
+ });
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
166
166
  expect(gs!.content).toContain("## Write gotchas");
167
167
  expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
168
168
  expect(gs!.content).toContain("force: true");
169
- // schema-default back-fill: enum→first value, integer→0.
170
- expect(gs!.content).toContain("first listed value");
169
+ // schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
170
+ expect(gs!.content).toContain("explicit `default`");
171
+ expect(gs!.content).toContain("exists: false");
172
+ // indexed⇒strict type enforcement (vault#553 Decision A).
173
+ expect(gs!.content).toContain("field's TYPE is always enforced");
171
174
  });
172
175
 
173
176
  test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
package/src/routes.ts CHANGED
@@ -13,7 +13,14 @@
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
- import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
16
+ import {
17
+ collectUnknownTagWarnings,
18
+ emptySearchWarning,
19
+ ignoredParamWarning,
20
+ computeSearchDidYouMean,
21
+ searchDidYouMeanWarning,
22
+ type QueryWarning,
23
+ } from "../core/src/query-warnings.ts";
17
24
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
18
25
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
19
26
  import { transactionAsync } from "../core/src/txn.ts";
@@ -24,7 +31,7 @@ import {
24
31
  contentRangeRequiresContent,
25
32
  type ContentRange,
26
33
  } from "../core/src/content-range.ts";
27
- import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
34
+ import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
28
35
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
29
36
  import { logStrictBypass } from "./scopes.ts";
30
37
  import * as linkOps from "../core/src/links.ts";
@@ -48,7 +55,7 @@ import {
48
55
  tagScopeForbidden,
49
56
  tagsWithinScope,
50
57
  } from "./tag-scope.ts";
51
- import { ParentCycleError } from "../core/src/tag-schemas.ts";
58
+ import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
52
59
  import { findTokensReferencingTag } from "./token-store.ts";
53
60
 
54
61
  /**
@@ -1103,6 +1110,22 @@ async function handleNotesInner(
1103
1110
  }
1104
1111
  throw e;
1105
1112
  }
1113
+ // Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
1114
+ // FTS5-vocabulary scan) and ONLY computed on the already-rare
1115
+ // empty-result path, mirroring `unknown_tag`'s did_you_mean.
1116
+ // Gated on `tagScope.allowed === null` (unscoped) — same stance
1117
+ // as `collectUnknownTagWarnings` below: the FTS5 vocabulary spans
1118
+ // the whole vault regardless of scope, so surfacing a suggestion
1119
+ // to a scoped token would leak out-of-scope content's existence
1120
+ // across the scope boundary. Unlike the MCP path, REST enforces
1121
+ // tag-scope inline (no post-hoc wrapper to strip it for us), so
1122
+ // the gate has to live here explicitly.
1123
+ if (rawResults.length === 0 && tagScope.allowed === null) {
1124
+ const suggestion = computeSearchDidYouMean(db, search);
1125
+ if (suggestion) {
1126
+ searchWarnings.push(searchDidYouMeanWarning(search, suggestion));
1127
+ }
1128
+ }
1106
1129
  }
1107
1130
  // Tag-scope: drop any result the token isn't permitted to see. Filter
1108
1131
  // happens after the store query so an empty post-filter list still
@@ -2668,6 +2691,18 @@ export async function handleTags(
2668
2691
  400,
2669
2692
  );
2670
2693
  }
2694
+ if (err instanceof InvalidFieldDefaultError) {
2695
+ // vault#553 Decision B — a declared `default` doesn't conform to its
2696
+ // own field's type/enum. Own-field error (no cross-tag data), so no
2697
+ // scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
2698
+ // store.upsertTagRecord's pre-validate comment for why REST hits
2699
+ // this (fail-fast, single violation) while MCP's update-tag usually
2700
+ // reports it bundled via `TagFieldConflictError` instead.
2701
+ return json(
2702
+ { error: err.message, error_type: "invalid_field_default", field: err.field },
2703
+ 400,
2704
+ );
2705
+ }
2671
2706
  if (err instanceof ParentCycleError) {
2672
2707
  // vault#552: parent_names would close a cycle. Nothing was
2673
2708
  // persisted. Scope-scrub the cycle path for a tag-scoped caller —
@@ -3735,56 +3770,10 @@ export async function handleStorage(
3735
3770
  return json({ error: "Not found", error_type: "not_found" }, 404);
3736
3771
  }
3737
3772
 
3738
- // ---------------------------------------------------------------------------
3739
- // Tag schema defaults same logic as core/src/mcp.ts applySchemaDefaults
3740
- // ---------------------------------------------------------------------------
3741
-
3742
- // Returns the IDs of notes whose metadata was actually default-filled, so
3743
- // the caller can re-read ONLY the mutated notes (and skip the re-read when
3744
- // nothing changed). Mirrors the core/src/mcp.ts contract.
3745
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
3746
- const schemas = tagSchemaOps.getTagSchemaMap(db);
3747
- if (Object.keys(schemas).length === 0) return [];
3748
-
3749
- const defaults: Record<string, unknown> = {};
3750
- for (const tag of tags) {
3751
- const schema = schemas[tag];
3752
- if (!schema?.fields) continue;
3753
- for (const [field, fieldSchema] of Object.entries(schema.fields)) {
3754
- if (!(field in defaults)) {
3755
- defaults[field] = defaultForField(fieldSchema);
3756
- }
3757
- }
3758
- }
3759
- if (Object.keys(defaults).length === 0) return [];
3760
-
3761
- const mutated: string[] = [];
3762
- for (const noteId of noteIds) {
3763
- const note = await store.getNote(noteId);
3764
- if (!note) continue;
3765
- const existing = (note.metadata as Record<string, unknown>) ?? {};
3766
- const missing: Record<string, unknown> = {};
3767
- for (const [field, value] of Object.entries(defaults)) {
3768
- if (!(field in existing)) missing[field] = value;
3769
- }
3770
- if (Object.keys(missing).length === 0) continue;
3771
- await store.updateNote(noteId, {
3772
- metadata: { ...existing, ...missing },
3773
- skipUpdatedAt: true,
3774
- });
3775
- mutated.push(noteId);
3776
- }
3777
- return mutated;
3778
- }
3779
-
3780
- function defaultForField(field: { type: string; enum?: string[] }): unknown {
3781
- if (field.enum && field.enum.length > 0) return field.enum[0];
3782
- switch (field.type) {
3783
- case "boolean": return false;
3784
- case "integer": return 0;
3785
- default: return "";
3786
- }
3787
- }
3773
+ // applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
3774
+ // carry a byte-identical duplicate; importing it means REST and MCP can
3775
+ // never drift on this behavior again, and the cloud runtime (which imports
3776
+ // core directly) inherits it without any handler-side code.
3788
3777
 
3789
3778
  function removeWikilinkBrackets(content: string, targetPath: string): string {
3790
3779
  const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
package/src/vault.test.ts CHANGED
@@ -979,7 +979,7 @@ describe("scoped MCP wrapper", async () => {
979
979
  closeAllStores();
980
980
  });
981
981
 
982
- test("create-note with schema tag auto-populates defaults", async () => {
982
+ test("create-note with schema tag applies explicit defaults; undeclared fields stay absent (vault#553 Decision B)", async () => {
983
983
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
984
984
  const { writeVaultConfig } = await import("./config.ts");
985
985
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
@@ -995,8 +995,8 @@ describe("scoped MCP wrapper", async () => {
995
995
  await vaultStore.upsertTagSchema("person", {
996
996
  description: "A person",
997
997
  fields: {
998
- first_appeared: { type: "string", description: "When" },
999
- relationship: { type: "string", description: "How" },
998
+ first_appeared: { type: "string", description: "When", default: "unknown" },
999
+ relationship: { type: "string", description: "How" }, // no default — stays absent
1000
1000
  },
1001
1001
  });
1002
1002
 
@@ -1004,19 +1004,19 @@ describe("scoped MCP wrapper", async () => {
1004
1004
  const createNote = tools.find((t) => t.name === "create-note")!;
1005
1005
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
1006
1006
 
1007
- // Create a note tagged person with no metadata — defaults auto-populated
1007
+ // Create a note tagged person with no metadata — the EXPLICIT default
1008
+ // auto-populates; the field with no `default` stays absent.
1008
1009
  const result = await createNote.execute({
1009
1010
  content: "Alice",
1010
1011
  tags: ["person"],
1011
1012
  }) as any;
1012
1013
  expect(result.content).toBe("Alice");
1013
1014
 
1014
- // Verify defaults were written
1015
1015
  const fresh = await queryNotes.execute({ id: result.id }) as any;
1016
- expect(fresh.metadata.first_appeared).toBe("");
1017
- expect(fresh.metadata.relationship).toBe("");
1016
+ expect(fresh.metadata.first_appeared).toBe("unknown");
1017
+ expect(fresh.metadata.relationship).toBeUndefined();
1018
1018
 
1019
- // Create with explicit metadata — preserved
1019
+ // Create with explicit metadata — preserved (overrides the default).
1020
1020
  const result2 = await createNote.execute({
1021
1021
  content: "Bob",
1022
1022
  tags: ["person"],
@@ -1029,7 +1029,7 @@ describe("scoped MCP wrapper", async () => {
1029
1029
  closeAllStores();
1030
1030
  });
1031
1031
 
1032
- test("update-note tags.add with schema auto-populates defaults", async () => {
1032
+ test("update-note tags.add with schema applies explicit defaults; undeclared fields stay absent (vault#553 Decision B)", async () => {
1033
1033
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1034
1034
  const { writeVaultConfig } = await import("./config.ts");
1035
1035
  const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
@@ -1045,16 +1045,16 @@ describe("scoped MCP wrapper", async () => {
1045
1045
  await vaultStore.upsertTagSchema("person", {
1046
1046
  description: "A person",
1047
1047
  fields: {
1048
- first_appeared: { type: "string", description: "When" },
1049
- relationship: { type: "string", description: "How" },
1048
+ first_appeared: { type: "string", description: "When", default: "unknown" },
1049
+ relationship: { type: "string", description: "How" }, // no default — stays absent
1050
1050
  },
1051
1051
  });
1052
1052
  await vaultStore.upsertTagSchema("project", {
1053
1053
  description: "A project",
1054
1054
  fields: {
1055
- status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status" },
1056
- active: { type: "boolean", description: "Is active" },
1057
- priority: { type: "integer", description: "Priority level" },
1055
+ status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status", default: "active" },
1056
+ active: { type: "boolean", description: "Is active", default: false },
1057
+ priority: { type: "integer", description: "Priority level" }, // no default — stays absent
1058
1058
  },
1059
1059
  });
1060
1060
  const tools = generateScopedMcpTools(vaultName);
@@ -1066,10 +1066,11 @@ describe("scoped MCP wrapper", async () => {
1066
1066
  const note = await createNote.execute({ content: "Alice" }) as any;
1067
1067
  await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
1068
1068
  const after = await queryNotes.execute({ id: note.id }) as any;
1069
- expect(after.metadata.first_appeared).toBe("");
1070
- expect(after.metadata.relationship).toBe("");
1069
+ expect(after.metadata.first_appeared).toBe("unknown");
1070
+ expect(after.metadata.relationship).toBeUndefined();
1071
1071
 
1072
- // Tag note that already has partial metadata — only missing fields populated
1072
+ // Tag note that already has partial metadata — only missing fields WITH a
1073
+ // declared default get populated; the field with no default stays absent.
1073
1074
  const note2 = await createNote.execute({
1074
1075
  content: "Bob",
1075
1076
  metadata: { first_appeared: "2023-11" },
@@ -1077,22 +1078,22 @@ describe("scoped MCP wrapper", async () => {
1077
1078
  await updateNote.execute({ id: note2.id, tags: { add: ["person"] }, force: true });
1078
1079
  const after2 = await queryNotes.execute({ id: note2.id }) as any;
1079
1080
  expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
1080
- expect(after2.metadata.relationship).toBe(""); // added
1081
+ expect(after2.metadata.relationship).toBeUndefined(); // no default — stays absent
1081
1082
 
1082
- // Tag with #project — enum defaults to first value, boolean to false, integer to 0
1083
+ // Tag with #project — declared defaults land; `priority` (no default) stays absent.
1083
1084
  const note4 = await createNote.execute({ content: "My Project" }) as any;
1084
1085
  await updateNote.execute({ id: note4.id, tags: { add: ["project"] }, force: true });
1085
1086
  const after4 = await queryNotes.execute({ id: note4.id }) as any;
1086
1087
  expect(after4.metadata.status).toBe("active");
1087
1088
  expect(after4.metadata.active).toBe(false);
1088
- expect(after4.metadata.priority).toBe(0);
1089
+ expect(after4.metadata.priority).toBeUndefined();
1089
1090
 
1090
- // Multiple schema tags at once — all defaults merged
1091
+ // Multiple schema tags at once — all EXPLICIT defaults merged.
1091
1092
  const note5 = await createNote.execute({ content: "Multi" }) as any;
1092
1093
  await updateNote.execute({ id: note5.id, tags: { add: ["person", "project"] }, force: true });
1093
1094
  const after5 = await queryNotes.execute({ id: note5.id }) as any;
1094
- expect(after5.metadata.first_appeared).toBe("");
1095
- expect(after5.metadata.relationship).toBe("");
1095
+ expect(after5.metadata.first_appeared).toBe("unknown");
1096
+ expect(after5.metadata.relationship).toBeUndefined();
1096
1097
  expect(after5.metadata.status).toBe("active");
1097
1098
  expect(after5.metadata.active).toBe(false);
1098
1099
 
@@ -1566,9 +1567,12 @@ describe("scoped MCP wrapper", async () => {
1566
1567
  });
1567
1568
 
1568
1569
  const vaultStore = getVaultStore(vaultName);
1570
+ // vault#553 Decision B: backfill is explicit-`default`-only — declare
1571
+ // one so this test still exercises an actual defaults-write (and can
1572
+ // assert it lands without bumping updatedAt).
1569
1573
  await vaultStore.upsertTagSchema("person", {
1570
1574
  description: "A person",
1571
- fields: { name: { type: "string" } },
1575
+ fields: { name: { type: "string", default: "unknown" } },
1572
1576
  });
1573
1577
 
1574
1578
  const tools = generateScopedMcpTools(vaultName);
@@ -1581,7 +1585,7 @@ describe("scoped MCP wrapper", async () => {
1581
1585
  await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
1582
1586
  const after = await queryNotes.execute({ id: note.id }) as any;
1583
1587
  expect(after.updatedAt).toBe(originalUpdatedAt);
1584
- expect(after.metadata.name).toBe("");
1588
+ expect(after.metadata.name).toBe("unknown");
1585
1589
 
1586
1590
  close();
1587
1591
  });
@@ -2199,8 +2203,10 @@ describe("HTTP /notes", async () => {
2199
2203
  // mapped over the pre-defaults in-memory objects, so default-filled
2200
2204
  // metadata was missing from `POST /api/notes` responses.
2201
2205
  test("POST /notes response reflects post-applySchemaDefaults state (vault#316)", async () => {
2206
+ // vault#553 Decision B: backfill is explicit-`default`-only — declare one
2207
+ // so this test still exercises the post-defaults re-read mechanism.
2202
2208
  await store.upsertTagSchema("task", {
2203
- fields: { priority: { type: "string", enum: ["high", "low"] } },
2209
+ fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
2204
2210
  });
2205
2211
 
2206
2212
  // Single: default lands in the returned metadata and agrees with disk.
@@ -2211,7 +2217,7 @@ describe("HTTP /notes", async () => {
2211
2217
  );
2212
2218
  expect(single.status).toBe(201);
2213
2219
  const singleBody = await single.json() as any;
2214
- expect(singleBody.metadata?.priority).toBe("high"); // first enum value
2220
+ expect(singleBody.metadata?.priority).toBe("high"); // explicit schema default
2215
2221
  const onDisk = await store.getNoteByPath("Inbox/task-1");
2216
2222
  expect((onDisk!.metadata as any)?.priority).toBe("high");
2217
2223