@openparachute/vault 0.7.2 → 0.7.3-rc.10

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.
Files changed (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
@@ -1478,6 +1478,42 @@ describe("queryNotes", async () => {
1478
1478
  expect(results.map((n) => n.content)).toEqual(["recent email"]);
1479
1479
  });
1480
1480
 
1481
+ // ---- `date` field type (vault#date-field-type) ----
1482
+ //
1483
+ // Same date_filter machinery as `email_date` above, but declared via a
1484
+ // real tag schema (`type: "date", indexed: true`) rather than raw
1485
+ // `declareField`, and mixing both accepted ISO forms (bare date + full
1486
+ // RFC3339 timestamp) in one vault.
1487
+ it("date_filter works on a schema-declared, indexed `date` field, accepting both ISO forms", async () => {
1488
+ const tools = generateMcpTools(store);
1489
+ // Indexed-field reconciliation (declareField → generated column + index)
1490
+ // runs through the `update-tag` tool / `store.upsertTagRecord` — the
1491
+ // legacy `store.upsertTagSchema` facade writes description+fields only
1492
+ // and does NOT create the backing column. See store.ts's upsertTagRecord.
1493
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
1494
+ await updateTag.execute({
1495
+ tag: "meeting",
1496
+ fields: { meeting_date: { type: "date", indexed: true } },
1497
+ });
1498
+ await store.createNote("too early", { tags: ["meeting"], metadata: { meeting_date: "2025-12-01" } });
1499
+ await store.createNote("in window (date-only)", { tags: ["meeting"], metadata: { meeting_date: "2026-04-15" } });
1500
+ await store.createNote("in window (full timestamp)", {
1501
+ tags: ["meeting"],
1502
+ metadata: { meeting_date: "2026-04-25T09:00:00.000Z" },
1503
+ });
1504
+ await store.createNote("too late", { tags: ["meeting"], metadata: { meeting_date: "2026-06-01" } });
1505
+
1506
+ const query = tools.find((t) => t.name === "query-notes")!;
1507
+ const results = await query.execute({
1508
+ date_filter: { field: "meeting_date", from: "2026-04-01", to: "2026-05-01" },
1509
+ include_content: true,
1510
+ }) as any[];
1511
+ expect(results.map((n) => n.content).sort()).toEqual([
1512
+ "in window (date-only)",
1513
+ "in window (full timestamp)",
1514
+ ]);
1515
+ });
1516
+
1481
1517
  // ---- updated_at filter (vault#285 friction point 1.5) ----
1482
1518
  //
1483
1519
  // Incremental-rebuild flows ask "what changed since X." Like `created_at`,
@@ -2230,6 +2266,50 @@ describe("queryNotes", async () => {
2230
2266
  store.queryNotes({ metadata: { priority: { in: 5 } as any } }),
2231
2267
  ).rejects.toThrow(/expects an array/);
2232
2268
  });
2269
+
2270
+ // ---- `date` field type: gt/gte/lt/lte + order_by (vault#date-field-type) ----
2271
+ //
2272
+ // Indexed `date` fields store TEXT (ISO-8601 strings compare correctly
2273
+ // lexicographically), so this exercises the SAME generic string-indexed-
2274
+ // field machinery `email_date`/`status` above already use — no
2275
+ // date-specific SQL was added. Uses a real tag schema (`type: "date",
2276
+ // indexed: true`), not raw `declareField`, to prove the wiring end to end.
2277
+ it("gt/gte/lt/lte compose into range queries on an indexed `date` field", async () => {
2278
+ // Indexed-field reconciliation (declareField → generated column +
2279
+ // index) runs through the `update-tag` tool / `store.upsertTagRecord`
2280
+ // — `store.upsertTagSchema` is the legacy description+fields-only
2281
+ // facade and does NOT create the backing column.
2282
+ const tools = generateMcpTools(store);
2283
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
2284
+ await updateTag.execute({
2285
+ tag: "meeting",
2286
+ fields: { meeting_date: { type: "date", indexed: true } },
2287
+ });
2288
+ await store.createNote("jan", { tags: ["meeting"], metadata: { meeting_date: "2026-01-15" } });
2289
+ await store.createNote("apr", { tags: ["meeting"], metadata: { meeting_date: "2026-04-15" } });
2290
+ await store.createNote("jul", { tags: ["meeting"], metadata: { meeting_date: "2026-07-09T14:30:00.000Z" } });
2291
+ await store.createNote("oct", { tags: ["meeting"], metadata: { meeting_date: "2026-10-15" } });
2292
+
2293
+ const range = await store.queryNotes({
2294
+ metadata: { meeting_date: { gte: "2026-04-01", lt: "2026-10-01" } },
2295
+ });
2296
+ expect(range.map((n) => n.content).sort()).toEqual(["apr", "jul"]);
2297
+ });
2298
+
2299
+ it("order_by sorts by an indexed `date` field", async () => {
2300
+ const tools = generateMcpTools(store);
2301
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
2302
+ await updateTag.execute({
2303
+ tag: "meeting",
2304
+ fields: { meeting_date: { type: "date", indexed: true } },
2305
+ });
2306
+ await store.createNote("later", { tags: ["meeting"], metadata: { meeting_date: "2026-07-09" } });
2307
+ await store.createNote("earlier", { tags: ["meeting"], metadata: { meeting_date: "2026-01-15" } });
2308
+ await store.createNote("middle", { tags: ["meeting"], metadata: { meeting_date: "2026-04-01" } });
2309
+
2310
+ const asc = await store.queryNotes({ orderBy: "meeting_date" });
2311
+ expect(asc.map((n) => n.content)).toEqual(["earlier", "middle", "later"]);
2312
+ });
2233
2313
  });
2234
2314
  });
2235
2315
 
@@ -3910,6 +3990,78 @@ describe("MCP tools", async () => {
3910
3990
  expect(result.updatedAt).toBe(note.updatedAt);
3911
3991
  });
3912
3992
 
3993
+ describe("metadata is always present on the wire (V1.1)", () => {
3994
+ it("a note created with no metadata reads back with an empty-object `metadata` key, not an absent one", async () => {
3995
+ const note = await store.createNote("No metadata here");
3996
+ expect("metadata" in note).toBe(true);
3997
+ expect(note.metadata).toEqual({});
3998
+ // The real acid test: does the key survive a JSON round-trip, or does
3999
+ // `JSON.stringify` drop it because it's `undefined`?
4000
+ expect(JSON.parse(JSON.stringify(note))).toHaveProperty("metadata", {});
4001
+
4002
+ const fetched = await store.getNote(note.id);
4003
+ expect(fetched).toHaveProperty("metadata", {});
4004
+ expect(JSON.parse(JSON.stringify(fetched))).toHaveProperty("metadata", {});
4005
+
4006
+ // `tags` has always been unconditionally present ([]) — confirm that
4007
+ // invariant still holds alongside the metadata fix.
4008
+ expect(note.tags).toEqual([]);
4009
+ });
4010
+
4011
+ it("a note whose metadata was explicitly written as {} also reads back with metadata: {}", async () => {
4012
+ const note = await store.createNote("Explicit empty metadata", { metadata: {} });
4013
+ expect(note.metadata).toEqual({});
4014
+ const fetched = await store.getNote(note.id);
4015
+ expect(fetched!.metadata).toEqual({});
4016
+ });
4017
+
4018
+ it("a note WITH real metadata is unchanged by the fix", async () => {
4019
+ const note = await store.createNote("Has metadata", { metadata: { source: "import", version: 2 } });
4020
+ expect(note.metadata).toEqual({ source: "import", version: 2 });
4021
+ const fetched = await store.getNote(note.id);
4022
+ expect(fetched!.metadata).toEqual({ source: "import", version: 2 });
4023
+ });
4024
+
4025
+ it("queryNotes list results carry metadata: {} on metadata-less notes", async () => {
4026
+ await store.createNote("List entry, no metadata");
4027
+ const results = await store.queryNotes({});
4028
+ expect(results.length).toBeGreaterThan(0);
4029
+ for (const r of results) {
4030
+ expect(r).toHaveProperty("metadata", {});
4031
+ }
4032
+ });
4033
+
4034
+ it("searchNotes results carry metadata: {} on metadata-less notes", async () => {
4035
+ await store.createNote("Findable via search, no metadata");
4036
+ const results = await store.searchNotes("Findable");
4037
+ expect(results.length).toBeGreaterThan(0);
4038
+ for (const r of results) {
4039
+ expect(r).toHaveProperty("metadata", {});
4040
+ }
4041
+ });
4042
+
4043
+ it("MCP query-notes (single by id) carries metadata: {} on a metadata-less note", async () => {
4044
+ const note = await store.createNote("MCP single, no metadata");
4045
+ const tools = generateMcpTools(store);
4046
+ const query = tools.find((t) => t.name === "query-notes")!;
4047
+ const result = await query.execute({ id: note.id }) as any;
4048
+ expect(result).toHaveProperty("metadata", {});
4049
+ expect(JSON.parse(JSON.stringify(result))).toHaveProperty("metadata", {});
4050
+ });
4051
+
4052
+ it("MCP query-notes (list) carries metadata: {} on metadata-less notes", async () => {
4053
+ await store.createNote("MCP list, no metadata");
4054
+ const tools = generateMcpTools(store);
4055
+ const query = tools.find((t) => t.name === "query-notes")!;
4056
+ const result = await query.execute({}) as any;
4057
+ const list = Array.isArray(result) ? result : result.notes;
4058
+ expect(list.length).toBeGreaterThan(0);
4059
+ for (const r of list) {
4060
+ expect(r).toHaveProperty("metadata", {});
4061
+ }
4062
+ });
4063
+ });
4064
+
3913
4065
  it("query-notes single note by path", async () => {
3914
4066
  await store.createNote("By Path", { path: "Projects/README" });
3915
4067
  const tools = generateMcpTools(store);
@@ -4173,14 +4325,21 @@ describe("MCP tools", async () => {
4173
4325
  expect(projects).toHaveLength(3);
4174
4326
  expect(projects.every((n) => n.path!.startsWith("Projects"))).toBe(true);
4175
4327
 
4176
- // limit + offset
4177
- const page = await query.execute({
4328
+ // limit + offset. Returning exactly `limit` rows with no cursor also
4329
+ // trips the V1.3 truncation-honesty warning (results.length === limit,
4330
+ // an inherent heuristic — it can't distinguish "exactly this many rows
4331
+ // exist" from "capped, more may follow" without a lookahead), which
4332
+ // wraps the response in { notes, warnings } instead of a bare array —
4333
+ // same envelope shape the tool already uses for other warnings.
4334
+ const pageResult = await query.execute({
4178
4335
  path_prefix: "Projects",
4179
4336
  sort: "asc",
4180
4337
  limit: 2,
4181
4338
  offset: 1,
4182
- }) as any[];
4339
+ }) as any;
4340
+ const page = Array.isArray(pageResult) ? pageResult : pageResult.notes;
4183
4341
  expect(page).toHaveLength(2);
4342
+ expect(pageResult.warnings?.some((w: any) => w.code === "truncated" && w.limit === 2)).toBe(true);
4184
4343
  });
4185
4344
 
4186
4345
  it("query-notes full-text search works", async () => {
@@ -4477,11 +4636,54 @@ describe("MCP tools", async () => {
4477
4636
  e: { type: "array" },
4478
4637
  f: { type: "object" },
4479
4638
  g: { type: "reference" },
4639
+ h: { type: "date" },
4480
4640
  },
4481
4641
  }) as any;
4482
4642
  expect(result.fields.a.type).toBe("string");
4483
4643
  expect(result.fields.f.type).toBe("object");
4484
4644
  expect(result.fields.g.type).toBe("reference");
4645
+ expect(result.fields.h.type).toBe("date");
4646
+ });
4647
+
4648
+ // vault#date-field-type: `date` stores/validates like `string` — an
4649
+ // ISO-8601 date or full RFC3339 timestamp — reusing cursor.ts's
4650
+ // `timestampToMs` (the same parser `date_filter`'s `updated_at` bound
4651
+ // uses) rather than a second, independently-drifting date parser.
4652
+ it("update-tag accepts a `date` field's default in both ISO forms", async () => {
4653
+ const tools = generateMcpTools(store);
4654
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4655
+ const dateOnly = await updateTag.execute({
4656
+ tag: "meeting",
4657
+ fields: { meeting_date: { type: "date", default: "2026-07-09" } },
4658
+ }) as any;
4659
+ expect(dateOnly.fields.meeting_date.default).toBe("2026-07-09");
4660
+
4661
+ const fullTimestamp = await updateTag.execute({
4662
+ tag: "meeting",
4663
+ fields: { meeting_date: { type: "date", default: "2026-07-09T14:30:00.000Z" } },
4664
+ }) as any;
4665
+ expect(fullTimestamp.fields.meeting_date.default).toBe("2026-07-09T14:30:00.000Z");
4666
+ });
4667
+
4668
+ it("update-tag rejects a non-ISO default on a `date` field (invalid_default)", async () => {
4669
+ const tools = generateMcpTools(store);
4670
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
4671
+ let caught: any;
4672
+ try {
4673
+ await updateTag.execute({
4674
+ tag: "meeting",
4675
+ fields: { meeting_date: { type: "date", default: "not-a-date" } },
4676
+ });
4677
+ } catch (e) {
4678
+ caught = e;
4679
+ }
4680
+ expect(caught).toBeDefined();
4681
+ expect(caught.error_type).toBe("tag_field_conflict");
4682
+ expect(caught.violations[0].field).toBe("meeting_date");
4683
+ expect(caught.violations[0].reason).toBe("invalid_default");
4684
+ // Nothing persisted.
4685
+ const record = await store.getTagRecord("meeting");
4686
+ expect(record?.fields ?? null).toBeFalsy();
4485
4687
  });
4486
4688
 
4487
4689
  // ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
@@ -5691,8 +5893,10 @@ describe("query-notes link expansion", async () => {
5691
5893
  expect(result[0].preview).toBeTruthy();
5692
5894
  });
5693
5895
 
5694
- it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
5695
- await store.createNote("unsummarized body", { path: "Plain" });
5896
+ it("expand_mode=summary with no metadata.summary AND no lede (title-only note) renders empty body inline", async () => {
5897
+ // Single-line content: the whole thing IS the title, so there's no
5898
+ // paragraph after it for computeLede to fall back to.
5899
+ await store.createNote("unsummarized title-only body", { path: "Plain" });
5696
5900
  await store.createNote("see [[Plain]]", { path: "Src" });
5697
5901
  const tools = generateMcpTools(store);
5698
5902
  const query = tools.find((t) => t.name === "query-notes")!;
@@ -5703,8 +5907,68 @@ describe("query-notes link expansion", async () => {
5703
5907
  expand_mode: "summary",
5704
5908
  }) as any;
5705
5909
  expect(result.content).toContain('mode="summary"');
5706
- // Summary is empty — we still get the block but with nothing between delimiters.
5707
- expect(result.content).not.toContain("unsummarized body");
5910
+ // No summary, no lede — we still get the block but with nothing between delimiters.
5911
+ expect(result.content).not.toContain("unsummarized title-only body");
5912
+ });
5913
+
5914
+ it("expand_mode=summary with no metadata.summary falls back to the note's lede", async () => {
5915
+ await store.createNote(
5916
+ "# Long canonical statement\n\nUnforced / wu wei, in one paragraph.\n\n(Many paragraphs of detail follow...)",
5917
+ { path: "Statements/NoSummary" },
5918
+ );
5919
+ await store.createNote("Overview: [[Statements/NoSummary]]", { path: "Index2" });
5920
+ const tools = generateMcpTools(store);
5921
+ const query = tools.find((t) => t.name === "query-notes")!;
5922
+
5923
+ const result = await query.execute({
5924
+ id: "Index2",
5925
+ expand_links: true,
5926
+ expand_mode: "summary",
5927
+ }) as any;
5928
+
5929
+ expect(result.content).toContain('mode="summary"');
5930
+ expect(result.content).toContain("Unforced / wu wei, in one paragraph.");
5931
+ expect(result.content).not.toContain("Many paragraphs of detail");
5932
+ // The heading/title itself isn't repeated as the summary.
5933
+ expect(result.content).not.toContain("Long canonical statement");
5934
+ });
5935
+
5936
+ it("expand_mode=summary: metadata.summary still wins over the lede when both are present", async () => {
5937
+ await store.createNote(
5938
+ "# Title\n\nThis is the lede paragraph, not the summary.",
5939
+ { path: "Statements/Both", metadata: { summary: "The curated summary." } },
5940
+ );
5941
+ await store.createNote("see [[Statements/Both]]", { path: "Index3" });
5942
+ const tools = generateMcpTools(store);
5943
+ const query = tools.find((t) => t.name === "query-notes")!;
5944
+
5945
+ const result = await query.execute({
5946
+ id: "Index3",
5947
+ expand_links: true,
5948
+ expand_mode: "summary",
5949
+ }) as any;
5950
+
5951
+ expect(result.content).toContain("The curated summary.");
5952
+ expect(result.content).not.toContain("This is the lede paragraph");
5953
+ });
5954
+
5955
+ it("expand_mode=summary lede fallback respects a leading frontmatter block", async () => {
5956
+ await store.createNote(
5957
+ "---\ntitle: X\n---\n# Real Title\n\nThe lede after frontmatter and title.",
5958
+ { path: "Statements/Frontmatter" },
5959
+ );
5960
+ await store.createNote("see [[Statements/Frontmatter]]", { path: "Index4" });
5961
+ const tools = generateMcpTools(store);
5962
+ const query = tools.find((t) => t.name === "query-notes")!;
5963
+
5964
+ const result = await query.execute({
5965
+ id: "Index4",
5966
+ expand_links: true,
5967
+ expand_mode: "summary",
5968
+ }) as any;
5969
+
5970
+ expect(result.content).toContain("The lede after frontmatter and title.");
5971
+ expect(result.content).not.toContain("Real Title");
5708
5972
  });
5709
5973
  });
5710
5974
 
@@ -5971,6 +6235,113 @@ describe("schema validation (tags.fields)", async () => {
5971
6235
  expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
5972
6236
  });
5973
6237
 
6238
+ // ---- `date` field type (vault#date-field-type) ----
6239
+ //
6240
+ // Motivation: today a date-ish field (e.g. a `meeting` tag's
6241
+ // `meeting_date`) can only be declared `type: "string"`, with "ISO date"
6242
+ // explained in prose — nothing can programmatically discover
6243
+ // date-candidate fields for a calendar view. `date` validates like
6244
+ // `string` but requires an ISO-8601 date or full RFC3339 timestamp,
6245
+ // parsed by the SAME `timestampToMs` (cursor.ts) `date_filter`'s
6246
+ // `updated_at` bound uses.
6247
+
6248
+ it("no warning when a `date` field's value is a bare ISO date (YYYY-MM-DD)", async () => {
6249
+ await store.upsertTagSchema("meeting", {
6250
+ fields: { meeting_date: { type: "date" } },
6251
+ });
6252
+ const tools = generateMcpTools(store);
6253
+ const create = tools.find((t) => t.name === "create-note")!;
6254
+ const result = await create.execute({
6255
+ content: "standup",
6256
+ tags: ["meeting"],
6257
+ metadata: { meeting_date: "2026-07-09" },
6258
+ }) as any;
6259
+ expect(result.validation_status.warnings).toEqual([]);
6260
+ });
6261
+
6262
+ it("no warning when a `date` field's value is a full RFC3339 timestamp", async () => {
6263
+ await store.upsertTagSchema("meeting", {
6264
+ fields: { meeting_date: { type: "date" } },
6265
+ });
6266
+ const tools = generateMcpTools(store);
6267
+ const create = tools.find((t) => t.name === "create-note")!;
6268
+ const result = await create.execute({
6269
+ content: "standup",
6270
+ tags: ["meeting"],
6271
+ metadata: { meeting_date: "2026-07-09T14:30:00.000Z" },
6272
+ }) as any;
6273
+ expect(result.validation_status.warnings).toEqual([]);
6274
+ });
6275
+
6276
+ it("type_mismatch warning when a `date` field's value isn't a parseable ISO string", async () => {
6277
+ await store.upsertTagSchema("meeting", {
6278
+ fields: { meeting_date: { type: "date" } },
6279
+ });
6280
+ const tools = generateMcpTools(store);
6281
+ const create = tools.find((t) => t.name === "create-note")!;
6282
+ const result = await create.execute({
6283
+ content: "standup",
6284
+ tags: ["meeting"],
6285
+ metadata: { meeting_date: "not-a-date" },
6286
+ }) as any;
6287
+ expect(result.validation_status.warnings).toHaveLength(1);
6288
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
6289
+ expect(result.validation_status.warnings[0].field).toBe("meeting_date");
6290
+ });
6291
+
6292
+ it("a `date` field's type_mismatch is a HARD REJECTION under strict:true, same as any other type", async () => {
6293
+ await store.upsertTagSchema("meeting", {
6294
+ fields: { meeting_date: { type: "date", strict: true } },
6295
+ });
6296
+ const tools = generateMcpTools(store);
6297
+ const create = tools.find((t) => t.name === "create-note")!;
6298
+ await expect(create.execute({
6299
+ content: "standup",
6300
+ tags: ["meeting"],
6301
+ metadata: { meeting_date: "not-a-date" },
6302
+ })).rejects.toThrow();
6303
+ });
6304
+
6305
+ it("an indexed `date` field's type_mismatch is ALWAYS a hard rejection, independent of strict (vault#553 Decision A)", async () => {
6306
+ await store.upsertTagSchema("meeting", {
6307
+ fields: { meeting_date: { type: "date", indexed: true } },
6308
+ });
6309
+ const tools = generateMcpTools(store);
6310
+ const create = tools.find((t) => t.name === "create-note")!;
6311
+ await expect(create.execute({
6312
+ content: "standup",
6313
+ tags: ["meeting"],
6314
+ metadata: { meeting_date: "not-a-date" },
6315
+ })).rejects.toThrow();
6316
+ });
6317
+
6318
+ // Sharp edge (docs/HTTP_API.md's "type: date" callout): a schema edit
6319
+ // that tightens an existing field from `string` to `date` does NOT
6320
+ // retroactively revalidate notes already carrying a non-ISO value —
6321
+ // only the field's NEXT write is checked. No migration, no data change.
6322
+ it("tightening an existing field from string to date does not retroactively touch existing notes", async () => {
6323
+ await store.upsertTagSchema("meeting", {
6324
+ fields: { meeting_date: { type: "string" } },
6325
+ });
6326
+ const tools = generateMcpTools(store);
6327
+ const create = tools.find((t) => t.name === "create-note")!;
6328
+ const query = tools.find((t) => t.name === "query-notes")!;
6329
+ const note = await create.execute({
6330
+ content: "old-style note",
6331
+ tags: ["meeting"],
6332
+ metadata: { meeting_date: "sometime next week" }, // not ISO — fine under type:"string"
6333
+ }) as any;
6334
+ expect(note.validation_status.warnings).toEqual([]);
6335
+
6336
+ // Tighten the schema. This must not touch the existing note.
6337
+ await store.upsertTagSchema("meeting", {
6338
+ fields: { meeting_date: { type: "date" } },
6339
+ });
6340
+
6341
+ const fresh = await query.execute({ id: note.id }) as any;
6342
+ expect(fresh.metadata.meeting_date).toBe("sometime next week");
6343
+ });
6344
+
5974
6345
  // vault#555 fix 3 — an enum-membership violation on an INDEXED
5975
6346
  // (non-strict) field is stored, findable via `eq` (indexed⇒strict is
5976
6347
  // TYPE-only, not enum-domain — a ratified policy, not a bug), but the
@@ -6267,6 +6638,212 @@ describe("schema validation (tags.fields)", async () => {
6267
6638
  });
6268
6639
  });
6269
6640
 
6641
+ // ---------------------------------------------------------------------------
6642
+ // `date` field type — offset normalization on write (vault#date-field-type
6643
+ // review fix). A raw TEXT compare only sorts ISO-8601 timestamps correctly
6644
+ // when every stored value shares the SAME offset representation.
6645
+ // `timestampToMs` correctly VALIDATES an explicit ±HH:MM offset, but the
6646
+ // value used to persist VERBATIM — a mixed-offset vault silently
6647
+ // mis-ordered/mis-filtered. `normalizeDateFields` (schema-defaults.ts),
6648
+ // wired into every store write path, rewrites a full timestamp to canonical
6649
+ // UTC (`Z`-suffixed) before the row is written.
6650
+ // ---------------------------------------------------------------------------
6651
+
6652
+ describe("date field type — offset normalization (vault#date-field-type review fix)", async () => {
6653
+ async function declareIndexedMeetingDate() {
6654
+ const tools = generateMcpTools(store);
6655
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
6656
+ await updateTag.execute({
6657
+ tag: "meeting",
6658
+ fields: { meeting_date: { type: "date", indexed: true } },
6659
+ });
6660
+ return tools;
6661
+ }
6662
+
6663
+ // The reviewer's exact repro: "2026-07-16T10:00:00+02:00" (= 08:00Z) vs
6664
+ // "2026-07-16T09:00:00Z" (= 09:00Z). Persisted verbatim, a raw string
6665
+ // compare gets these BACKWARDS (comparing "10" vs "09" after the shared
6666
+ // "2026-07-16T" prefix says the +02:00 value sorts LATER, when its actual
6667
+ // instant — 08:00Z — is EARLIER). Post-normalization both values are
6668
+ // Z-form UTC, so the string compare IS the instant compare.
6669
+ it("order_by ASC returns mixed-offset values in true chronological order", async () => {
6670
+ const tools = await declareIndexedMeetingDate();
6671
+ const create = tools.find((t) => t.name === "create-note")!;
6672
+ await create.execute({
6673
+ content: "earlier (offset form)",
6674
+ tags: ["meeting"],
6675
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" }, // = 08:00Z
6676
+ });
6677
+ await create.execute({
6678
+ content: "later (Z form)",
6679
+ tags: ["meeting"],
6680
+ metadata: { meeting_date: "2026-07-16T09:00:00Z" }, // = 09:00Z
6681
+ });
6682
+
6683
+ const asc = await store.queryNotes({ orderBy: "meeting_date" });
6684
+ expect(asc.map((n) => n.content)).toEqual(["earlier (offset form)", "later (Z form)"]);
6685
+ });
6686
+
6687
+ it("a gt bound correctly excludes an earlier instant expressed with an offset", async () => {
6688
+ const tools = await declareIndexedMeetingDate();
6689
+ const create = tools.find((t) => t.name === "create-note")!;
6690
+ await create.execute({
6691
+ content: "before threshold (offset form, = 08:00Z)",
6692
+ tags: ["meeting"],
6693
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6694
+ });
6695
+ await create.execute({
6696
+ content: "after threshold (Z form, = 09:00Z)",
6697
+ tags: ["meeting"],
6698
+ metadata: { meeting_date: "2026-07-16T09:00:00Z" },
6699
+ });
6700
+
6701
+ // Threshold sits BETWEEN the two instants (08:30Z). Pre-fix, the
6702
+ // unnormalized "+02:00" value's raw string ("...10:00:00+02:00")
6703
+ // compared GREATER than the threshold string despite its instant being
6704
+ // earlier — a false match. Post-fix it correctly falls below.
6705
+ const results = await store.queryNotes({
6706
+ metadata: { meeting_date: { gt: "2026-07-16T08:30:00Z" } },
6707
+ });
6708
+ expect(results.map((n) => n.content)).toEqual(["after threshold (Z form, = 09:00Z)"]);
6709
+ });
6710
+
6711
+ it("create-note normalizes an offset timestamp to canonical Z-form; the stored/returned value is not the verbatim input", async () => {
6712
+ const tools = await declareIndexedMeetingDate();
6713
+ const create = tools.find((t) => t.name === "create-note")!;
6714
+ const query = tools.find((t) => t.name === "query-notes")!;
6715
+ const created = await create.execute({
6716
+ content: "standup",
6717
+ tags: ["meeting"],
6718
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6719
+ }) as any;
6720
+
6721
+ // The create response itself already reflects the normalized value...
6722
+ expect(created.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6723
+ // ...and so does a fresh read-back (round-trips through the DB, not an echo).
6724
+ const fresh = await query.execute({ id: created.id }) as any;
6725
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6726
+ });
6727
+
6728
+ it("update-note (merge-patch) normalizes an offset timestamp the same way create-note does", async () => {
6729
+ const tools = await declareIndexedMeetingDate();
6730
+ const create = tools.find((t) => t.name === "create-note")!;
6731
+ const update = tools.find((t) => t.name === "update-note")!;
6732
+ const query = tools.find((t) => t.name === "query-notes")!;
6733
+ const created = await create.execute({
6734
+ content: "standup",
6735
+ tags: ["meeting"],
6736
+ }) as any;
6737
+
6738
+ await update.execute({
6739
+ id: created.id,
6740
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6741
+ force: true,
6742
+ });
6743
+
6744
+ const fresh = await query.execute({ id: created.id }) as any;
6745
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6746
+ });
6747
+
6748
+ // ---- round 2: tag-add + date-metadata in the SAME call (vault#date-
6749
+ // field-type review round 2) ----
6750
+ //
6751
+ // `store.tagNote` (the actual tag mutation) runs AFTER the core UPDATE in
6752
+ // both mcp.ts call sites that combine tags + metadata in one item — the
6753
+ // reviewer live-verified that without passing the PROJECTED tag set into
6754
+ // `store.updateNote`, normalization resolved the schema against the
6755
+ // note's STALE pre-write tags and missed a field newly declared by the
6756
+ // tag being added in this same call.
6757
+
6758
+ it("update-note: tags.add + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
6759
+ const tools = await declareIndexedMeetingDate();
6760
+ const create = tools.find((t) => t.name === "create-note")!;
6761
+ const update = tools.find((t) => t.name === "update-note")!;
6762
+ const query = tools.find((t) => t.name === "query-notes")!;
6763
+ // Bare, untagged note — "meeting" (and its meeting_date:date field) is
6764
+ // NOT yet on this note; it's added in the SAME update-note call below.
6765
+ const created = await create.execute({ content: "standup" }) as any;
6766
+
6767
+ const result = await update.execute({
6768
+ id: created.id,
6769
+ tags: { add: ["meeting"] },
6770
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6771
+ force: true,
6772
+ }) as any;
6773
+
6774
+ expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6775
+ const fresh = await query.execute({ id: created.id }) as any;
6776
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6777
+ expect(fresh.tags).toContain("meeting");
6778
+ });
6779
+
6780
+ it("create-note if_exists:update: tags + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
6781
+ const tools = await declareIndexedMeetingDate();
6782
+ const create = tools.find((t) => t.name === "create-note")!;
6783
+ const query = tools.find((t) => t.name === "query-notes")!;
6784
+ const created = await create.execute({ content: "standup", path: "Inbox/round2-upsert" }) as any;
6785
+
6786
+ const result = await create.execute({
6787
+ path: "Inbox/round2-upsert",
6788
+ tags: ["meeting"],
6789
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6790
+ if_exists: "update",
6791
+ }) as any;
6792
+
6793
+ expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6794
+ const fresh = await query.execute({ id: created.id }) as any;
6795
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6796
+ expect(fresh.tags).toContain("meeting");
6797
+ });
6798
+
6799
+ // ---- round 2: copy-on-write, never mutate the caller's object ----
6800
+
6801
+ it("store.createNote does NOT mutate the caller's metadata object", async () => {
6802
+ await declareIndexedMeetingDate();
6803
+ const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
6804
+ const callerMetadataSnapshot = { ...callerMetadata };
6805
+ await store.createNote("standup", { tags: ["meeting"], metadata: callerMetadata });
6806
+ // The caller's own object must be byte-identical to before the call —
6807
+ // core is a published library; a direct embedder's reference must
6808
+ // never change out from under them.
6809
+ expect(callerMetadata).toEqual(callerMetadataSnapshot);
6810
+ });
6811
+
6812
+ it("store.updateNote does NOT mutate the caller's metadata object", async () => {
6813
+ await declareIndexedMeetingDate();
6814
+ const note = await store.createNote("standup", { tags: ["meeting"] });
6815
+ const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
6816
+ const callerMetadataSnapshot = { ...callerMetadata };
6817
+ await store.updateNote(note.id, { metadata: callerMetadata });
6818
+ expect(callerMetadata).toEqual(callerMetadataSnapshot);
6819
+ });
6820
+
6821
+ it("a bare YYYY-MM-DD value passes through untouched (no offset to normalize)", async () => {
6822
+ const tools = await declareIndexedMeetingDate();
6823
+ const create = tools.find((t) => t.name === "create-note")!;
6824
+ const query = tools.find((t) => t.name === "query-notes")!;
6825
+ const created = await create.execute({
6826
+ content: "standup",
6827
+ tags: ["meeting"],
6828
+ metadata: { meeting_date: "2026-07-16" },
6829
+ }) as any;
6830
+ expect(created.metadata.meeting_date).toBe("2026-07-16");
6831
+ const fresh = await query.execute({ id: created.id }) as any;
6832
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16");
6833
+ });
6834
+
6835
+ it("a value already in canonical Z-form passes through unchanged (no spurious rewrite)", async () => {
6836
+ const tools = await declareIndexedMeetingDate();
6837
+ const create = tools.find((t) => t.name === "create-note")!;
6838
+ const created = await create.execute({
6839
+ content: "standup",
6840
+ tags: ["meeting"],
6841
+ metadata: { meeting_date: "2026-07-16T09:00:00.000Z" },
6842
+ }) as any;
6843
+ expect(created.metadata.meeting_date).toBe("2026-07-16T09:00:00.000Z");
6844
+ });
6845
+ });
6846
+
6270
6847
  // ---------------------------------------------------------------------------
6271
6848
  // update-note `if_missing: "create"` — idempotent upsert (vault#309)
6272
6849
  // ---------------------------------------------------------------------------
@@ -6614,10 +7191,10 @@ describe("create-note if_exists (vault#555)", async () => {
6614
7191
  if_exists: "replace",
6615
7192
  }) as any;
6616
7193
  expect(second.content).toBe("");
6617
- // An empty `{}` metadata collapses to `undefined` on read — the same
6618
- // convention every note with no metadata follows (rowToNote), not
6619
- // something special about `replace`.
6620
- expect(second.metadata).toBeUndefined();
7194
+ // An empty `{}` metadata reads back as `{}`, not an absent key — the
7195
+ // same convention every note with no metadata follows (rowToNote,
7196
+ // vault V1.1), not something special about `replace`.
7197
+ expect(second.metadata).toEqual({});
6621
7198
  });
6622
7199
 
6623
7200
  it("if_exists is a no-op without a path — a pathless create can never conflict", async () => {
@@ -8049,6 +8626,25 @@ describe("vault projection (vault#271)", async () => {
8049
8626
  expect(byName.priority.tags).toEqual(["project"]);
8050
8627
  });
8051
8628
 
8629
+ // vault#date-field-type: `type: "date"` carries through the indexed-field
8630
+ // catalog like any other type — nothing in the projection filters unknown
8631
+ // or newer types out.
8632
+ it("catalogs an indexed `date` field with its declared type", async () => {
8633
+ const { buildVaultProjection } = await import("./vault-projection.ts");
8634
+ const tools = generateMcpTools(store);
8635
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
8636
+ await updateTag.execute({
8637
+ tag: "meeting",
8638
+ fields: { meeting_date: { type: "date", indexed: true } },
8639
+ });
8640
+
8641
+ const projection = buildVaultProjection(db);
8642
+ const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
8643
+ expect(byName.meeting_date).toBeTruthy();
8644
+ expect(byName.meeting_date.type).toBe("date");
8645
+ expect(byName.meeting_date.tags).toEqual(["meeting"]);
8646
+ });
8647
+
8052
8648
  it("includes the static query-hint catalog", async () => {
8053
8649
  const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
8054
8650
  const projection = buildVaultProjection(db);