@openparachute/vault 0.7.3-rc.2 → 0.7.3-rc.4

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.
@@ -78,7 +78,8 @@ function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
78
78
  spec.type === "integer" ||
79
79
  spec.type === "boolean" ||
80
80
  spec.type === "array" ||
81
- spec.type === "object"
81
+ spec.type === "object" ||
82
+ spec.type === "date"
82
83
  ) {
83
84
  out.type = spec.type;
84
85
  }
@@ -220,14 +220,15 @@ describe("contract: typed indexes — Decision B: explicit-default-only enum bac
220
220
  });
221
221
 
222
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/reference are indexable", () => {
223
+ it("the update-tag field-type description clarifies only string/integer/boolean/reference/date are indexable", () => {
224
224
  const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
225
225
  const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
226
226
  // Honest about the full storage/advisory vocabulary AND the indexable subset.
227
227
  expect(typeDesc).toContain("number");
228
228
  // vault#typed-reference-field: `reference` joined the indexable subset
229
- // alongside string/integer/boolean.
230
- expect(typeDesc).toContain("Only string/integer/boolean/reference are INDEXABLE");
229
+ // alongside string/integer/boolean. vault#date-field-type: `date` joined
230
+ // the same subset — it stores TEXT (ISO-8601), same as string/reference.
231
+ expect(typeDesc).toContain("Only string/integer/boolean/reference/date are INDEXABLE");
231
232
  });
232
233
 
233
234
  it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
@@ -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
 
@@ -4556,11 +4636,54 @@ describe("MCP tools", async () => {
4556
4636
  e: { type: "array" },
4557
4637
  f: { type: "object" },
4558
4638
  g: { type: "reference" },
4639
+ h: { type: "date" },
4559
4640
  },
4560
4641
  }) as any;
4561
4642
  expect(result.fields.a.type).toBe("string");
4562
4643
  expect(result.fields.f.type).toBe("object");
4563
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();
4564
4687
  });
4565
4688
 
4566
4689
  // ---- Typed reference field: indexed value + auto-link (vault#typed-reference-field) ----
@@ -6050,6 +6173,113 @@ describe("schema validation (tags.fields)", async () => {
6050
6173
  expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
6051
6174
  });
6052
6175
 
6176
+ // ---- `date` field type (vault#date-field-type) ----
6177
+ //
6178
+ // Motivation: today a date-ish field (e.g. a `meeting` tag's
6179
+ // `meeting_date`) can only be declared `type: "string"`, with "ISO date"
6180
+ // explained in prose — nothing can programmatically discover
6181
+ // date-candidate fields for a calendar view. `date` validates like
6182
+ // `string` but requires an ISO-8601 date or full RFC3339 timestamp,
6183
+ // parsed by the SAME `timestampToMs` (cursor.ts) `date_filter`'s
6184
+ // `updated_at` bound uses.
6185
+
6186
+ it("no warning when a `date` field's value is a bare ISO date (YYYY-MM-DD)", async () => {
6187
+ await store.upsertTagSchema("meeting", {
6188
+ fields: { meeting_date: { type: "date" } },
6189
+ });
6190
+ const tools = generateMcpTools(store);
6191
+ const create = tools.find((t) => t.name === "create-note")!;
6192
+ const result = await create.execute({
6193
+ content: "standup",
6194
+ tags: ["meeting"],
6195
+ metadata: { meeting_date: "2026-07-09" },
6196
+ }) as any;
6197
+ expect(result.validation_status.warnings).toEqual([]);
6198
+ });
6199
+
6200
+ it("no warning when a `date` field's value is a full RFC3339 timestamp", async () => {
6201
+ await store.upsertTagSchema("meeting", {
6202
+ fields: { meeting_date: { type: "date" } },
6203
+ });
6204
+ const tools = generateMcpTools(store);
6205
+ const create = tools.find((t) => t.name === "create-note")!;
6206
+ const result = await create.execute({
6207
+ content: "standup",
6208
+ tags: ["meeting"],
6209
+ metadata: { meeting_date: "2026-07-09T14:30:00.000Z" },
6210
+ }) as any;
6211
+ expect(result.validation_status.warnings).toEqual([]);
6212
+ });
6213
+
6214
+ it("type_mismatch warning when a `date` field's value isn't a parseable ISO string", async () => {
6215
+ await store.upsertTagSchema("meeting", {
6216
+ fields: { meeting_date: { type: "date" } },
6217
+ });
6218
+ const tools = generateMcpTools(store);
6219
+ const create = tools.find((t) => t.name === "create-note")!;
6220
+ const result = await create.execute({
6221
+ content: "standup",
6222
+ tags: ["meeting"],
6223
+ metadata: { meeting_date: "not-a-date" },
6224
+ }) as any;
6225
+ expect(result.validation_status.warnings).toHaveLength(1);
6226
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
6227
+ expect(result.validation_status.warnings[0].field).toBe("meeting_date");
6228
+ });
6229
+
6230
+ it("a `date` field's type_mismatch is a HARD REJECTION under strict:true, same as any other type", async () => {
6231
+ await store.upsertTagSchema("meeting", {
6232
+ fields: { meeting_date: { type: "date", strict: true } },
6233
+ });
6234
+ const tools = generateMcpTools(store);
6235
+ const create = tools.find((t) => t.name === "create-note")!;
6236
+ await expect(create.execute({
6237
+ content: "standup",
6238
+ tags: ["meeting"],
6239
+ metadata: { meeting_date: "not-a-date" },
6240
+ })).rejects.toThrow();
6241
+ });
6242
+
6243
+ it("an indexed `date` field's type_mismatch is ALWAYS a hard rejection, independent of strict (vault#553 Decision A)", async () => {
6244
+ await store.upsertTagSchema("meeting", {
6245
+ fields: { meeting_date: { type: "date", indexed: true } },
6246
+ });
6247
+ const tools = generateMcpTools(store);
6248
+ const create = tools.find((t) => t.name === "create-note")!;
6249
+ await expect(create.execute({
6250
+ content: "standup",
6251
+ tags: ["meeting"],
6252
+ metadata: { meeting_date: "not-a-date" },
6253
+ })).rejects.toThrow();
6254
+ });
6255
+
6256
+ // Sharp edge (docs/HTTP_API.md's "type: date" callout): a schema edit
6257
+ // that tightens an existing field from `string` to `date` does NOT
6258
+ // retroactively revalidate notes already carrying a non-ISO value —
6259
+ // only the field's NEXT write is checked. No migration, no data change.
6260
+ it("tightening an existing field from string to date does not retroactively touch existing notes", async () => {
6261
+ await store.upsertTagSchema("meeting", {
6262
+ fields: { meeting_date: { type: "string" } },
6263
+ });
6264
+ const tools = generateMcpTools(store);
6265
+ const create = tools.find((t) => t.name === "create-note")!;
6266
+ const query = tools.find((t) => t.name === "query-notes")!;
6267
+ const note = await create.execute({
6268
+ content: "old-style note",
6269
+ tags: ["meeting"],
6270
+ metadata: { meeting_date: "sometime next week" }, // not ISO — fine under type:"string"
6271
+ }) as any;
6272
+ expect(note.validation_status.warnings).toEqual([]);
6273
+
6274
+ // Tighten the schema. This must not touch the existing note.
6275
+ await store.upsertTagSchema("meeting", {
6276
+ fields: { meeting_date: { type: "date" } },
6277
+ });
6278
+
6279
+ const fresh = await query.execute({ id: note.id }) as any;
6280
+ expect(fresh.metadata.meeting_date).toBe("sometime next week");
6281
+ });
6282
+
6053
6283
  // vault#555 fix 3 — an enum-membership violation on an INDEXED
6054
6284
  // (non-strict) field is stored, findable via `eq` (indexed⇒strict is
6055
6285
  // TYPE-only, not enum-domain — a ratified policy, not a bug), but the
@@ -6346,6 +6576,212 @@ describe("schema validation (tags.fields)", async () => {
6346
6576
  });
6347
6577
  });
6348
6578
 
6579
+ // ---------------------------------------------------------------------------
6580
+ // `date` field type — offset normalization on write (vault#date-field-type
6581
+ // review fix). A raw TEXT compare only sorts ISO-8601 timestamps correctly
6582
+ // when every stored value shares the SAME offset representation.
6583
+ // `timestampToMs` correctly VALIDATES an explicit ±HH:MM offset, but the
6584
+ // value used to persist VERBATIM — a mixed-offset vault silently
6585
+ // mis-ordered/mis-filtered. `normalizeDateFields` (schema-defaults.ts),
6586
+ // wired into every store write path, rewrites a full timestamp to canonical
6587
+ // UTC (`Z`-suffixed) before the row is written.
6588
+ // ---------------------------------------------------------------------------
6589
+
6590
+ describe("date field type — offset normalization (vault#date-field-type review fix)", async () => {
6591
+ async function declareIndexedMeetingDate() {
6592
+ const tools = generateMcpTools(store);
6593
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
6594
+ await updateTag.execute({
6595
+ tag: "meeting",
6596
+ fields: { meeting_date: { type: "date", indexed: true } },
6597
+ });
6598
+ return tools;
6599
+ }
6600
+
6601
+ // The reviewer's exact repro: "2026-07-16T10:00:00+02:00" (= 08:00Z) vs
6602
+ // "2026-07-16T09:00:00Z" (= 09:00Z). Persisted verbatim, a raw string
6603
+ // compare gets these BACKWARDS (comparing "10" vs "09" after the shared
6604
+ // "2026-07-16T" prefix says the +02:00 value sorts LATER, when its actual
6605
+ // instant — 08:00Z — is EARLIER). Post-normalization both values are
6606
+ // Z-form UTC, so the string compare IS the instant compare.
6607
+ it("order_by ASC returns mixed-offset values in true chronological order", async () => {
6608
+ const tools = await declareIndexedMeetingDate();
6609
+ const create = tools.find((t) => t.name === "create-note")!;
6610
+ await create.execute({
6611
+ content: "earlier (offset form)",
6612
+ tags: ["meeting"],
6613
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" }, // = 08:00Z
6614
+ });
6615
+ await create.execute({
6616
+ content: "later (Z form)",
6617
+ tags: ["meeting"],
6618
+ metadata: { meeting_date: "2026-07-16T09:00:00Z" }, // = 09:00Z
6619
+ });
6620
+
6621
+ const asc = await store.queryNotes({ orderBy: "meeting_date" });
6622
+ expect(asc.map((n) => n.content)).toEqual(["earlier (offset form)", "later (Z form)"]);
6623
+ });
6624
+
6625
+ it("a gt bound correctly excludes an earlier instant expressed with an offset", async () => {
6626
+ const tools = await declareIndexedMeetingDate();
6627
+ const create = tools.find((t) => t.name === "create-note")!;
6628
+ await create.execute({
6629
+ content: "before threshold (offset form, = 08:00Z)",
6630
+ tags: ["meeting"],
6631
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6632
+ });
6633
+ await create.execute({
6634
+ content: "after threshold (Z form, = 09:00Z)",
6635
+ tags: ["meeting"],
6636
+ metadata: { meeting_date: "2026-07-16T09:00:00Z" },
6637
+ });
6638
+
6639
+ // Threshold sits BETWEEN the two instants (08:30Z). Pre-fix, the
6640
+ // unnormalized "+02:00" value's raw string ("...10:00:00+02:00")
6641
+ // compared GREATER than the threshold string despite its instant being
6642
+ // earlier — a false match. Post-fix it correctly falls below.
6643
+ const results = await store.queryNotes({
6644
+ metadata: { meeting_date: { gt: "2026-07-16T08:30:00Z" } },
6645
+ });
6646
+ expect(results.map((n) => n.content)).toEqual(["after threshold (Z form, = 09:00Z)"]);
6647
+ });
6648
+
6649
+ it("create-note normalizes an offset timestamp to canonical Z-form; the stored/returned value is not the verbatim input", async () => {
6650
+ const tools = await declareIndexedMeetingDate();
6651
+ const create = tools.find((t) => t.name === "create-note")!;
6652
+ const query = tools.find((t) => t.name === "query-notes")!;
6653
+ const created = await create.execute({
6654
+ content: "standup",
6655
+ tags: ["meeting"],
6656
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6657
+ }) as any;
6658
+
6659
+ // The create response itself already reflects the normalized value...
6660
+ expect(created.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6661
+ // ...and so does a fresh read-back (round-trips through the DB, not an echo).
6662
+ const fresh = await query.execute({ id: created.id }) as any;
6663
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6664
+ });
6665
+
6666
+ it("update-note (merge-patch) normalizes an offset timestamp the same way create-note does", async () => {
6667
+ const tools = await declareIndexedMeetingDate();
6668
+ const create = tools.find((t) => t.name === "create-note")!;
6669
+ const update = tools.find((t) => t.name === "update-note")!;
6670
+ const query = tools.find((t) => t.name === "query-notes")!;
6671
+ const created = await create.execute({
6672
+ content: "standup",
6673
+ tags: ["meeting"],
6674
+ }) as any;
6675
+
6676
+ await update.execute({
6677
+ id: created.id,
6678
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6679
+ force: true,
6680
+ });
6681
+
6682
+ const fresh = await query.execute({ id: created.id }) as any;
6683
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6684
+ });
6685
+
6686
+ // ---- round 2: tag-add + date-metadata in the SAME call (vault#date-
6687
+ // field-type review round 2) ----
6688
+ //
6689
+ // `store.tagNote` (the actual tag mutation) runs AFTER the core UPDATE in
6690
+ // both mcp.ts call sites that combine tags + metadata in one item — the
6691
+ // reviewer live-verified that without passing the PROJECTED tag set into
6692
+ // `store.updateNote`, normalization resolved the schema against the
6693
+ // note's STALE pre-write tags and missed a field newly declared by the
6694
+ // tag being added in this same call.
6695
+
6696
+ it("update-note: tags.add + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
6697
+ const tools = await declareIndexedMeetingDate();
6698
+ const create = tools.find((t) => t.name === "create-note")!;
6699
+ const update = tools.find((t) => t.name === "update-note")!;
6700
+ const query = tools.find((t) => t.name === "query-notes")!;
6701
+ // Bare, untagged note — "meeting" (and its meeting_date:date field) is
6702
+ // NOT yet on this note; it's added in the SAME update-note call below.
6703
+ const created = await create.execute({ content: "standup" }) as any;
6704
+
6705
+ const result = await update.execute({
6706
+ id: created.id,
6707
+ tags: { add: ["meeting"] },
6708
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6709
+ force: true,
6710
+ }) as any;
6711
+
6712
+ expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6713
+ const fresh = await query.execute({ id: created.id }) as any;
6714
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6715
+ expect(fresh.tags).toContain("meeting");
6716
+ });
6717
+
6718
+ it("create-note if_exists:update: tags + an offset date value in the SAME call still normalizes (bare, untagged note)", async () => {
6719
+ const tools = await declareIndexedMeetingDate();
6720
+ const create = tools.find((t) => t.name === "create-note")!;
6721
+ const query = tools.find((t) => t.name === "query-notes")!;
6722
+ const created = await create.execute({ content: "standup", path: "Inbox/round2-upsert" }) as any;
6723
+
6724
+ const result = await create.execute({
6725
+ path: "Inbox/round2-upsert",
6726
+ tags: ["meeting"],
6727
+ metadata: { meeting_date: "2026-07-16T10:00:00+02:00" },
6728
+ if_exists: "update",
6729
+ }) as any;
6730
+
6731
+ expect(result.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6732
+ const fresh = await query.execute({ id: created.id }) as any;
6733
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16T08:00:00.000Z");
6734
+ expect(fresh.tags).toContain("meeting");
6735
+ });
6736
+
6737
+ // ---- round 2: copy-on-write, never mutate the caller's object ----
6738
+
6739
+ it("store.createNote does NOT mutate the caller's metadata object", async () => {
6740
+ await declareIndexedMeetingDate();
6741
+ const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
6742
+ const callerMetadataSnapshot = { ...callerMetadata };
6743
+ await store.createNote("standup", { tags: ["meeting"], metadata: callerMetadata });
6744
+ // The caller's own object must be byte-identical to before the call —
6745
+ // core is a published library; a direct embedder's reference must
6746
+ // never change out from under them.
6747
+ expect(callerMetadata).toEqual(callerMetadataSnapshot);
6748
+ });
6749
+
6750
+ it("store.updateNote does NOT mutate the caller's metadata object", async () => {
6751
+ await declareIndexedMeetingDate();
6752
+ const note = await store.createNote("standup", { tags: ["meeting"] });
6753
+ const callerMetadata = { meeting_date: "2026-07-16T10:00:00+02:00" };
6754
+ const callerMetadataSnapshot = { ...callerMetadata };
6755
+ await store.updateNote(note.id, { metadata: callerMetadata });
6756
+ expect(callerMetadata).toEqual(callerMetadataSnapshot);
6757
+ });
6758
+
6759
+ it("a bare YYYY-MM-DD value passes through untouched (no offset to normalize)", async () => {
6760
+ const tools = await declareIndexedMeetingDate();
6761
+ const create = tools.find((t) => t.name === "create-note")!;
6762
+ const query = tools.find((t) => t.name === "query-notes")!;
6763
+ const created = await create.execute({
6764
+ content: "standup",
6765
+ tags: ["meeting"],
6766
+ metadata: { meeting_date: "2026-07-16" },
6767
+ }) as any;
6768
+ expect(created.metadata.meeting_date).toBe("2026-07-16");
6769
+ const fresh = await query.execute({ id: created.id }) as any;
6770
+ expect(fresh.metadata.meeting_date).toBe("2026-07-16");
6771
+ });
6772
+
6773
+ it("a value already in canonical Z-form passes through unchanged (no spurious rewrite)", async () => {
6774
+ const tools = await declareIndexedMeetingDate();
6775
+ const create = tools.find((t) => t.name === "create-note")!;
6776
+ const created = await create.execute({
6777
+ content: "standup",
6778
+ tags: ["meeting"],
6779
+ metadata: { meeting_date: "2026-07-16T09:00:00.000Z" },
6780
+ }) as any;
6781
+ expect(created.metadata.meeting_date).toBe("2026-07-16T09:00:00.000Z");
6782
+ });
6783
+ });
6784
+
6349
6785
  // ---------------------------------------------------------------------------
6350
6786
  // update-note `if_missing: "create"` — idempotent upsert (vault#309)
6351
6787
  // ---------------------------------------------------------------------------
@@ -8128,6 +8564,25 @@ describe("vault projection (vault#271)", async () => {
8128
8564
  expect(byName.priority.tags).toEqual(["project"]);
8129
8565
  });
8130
8566
 
8567
+ // vault#date-field-type: `type: "date"` carries through the indexed-field
8568
+ // catalog like any other type — nothing in the projection filters unknown
8569
+ // or newer types out.
8570
+ it("catalogs an indexed `date` field with its declared type", async () => {
8571
+ const { buildVaultProjection } = await import("./vault-projection.ts");
8572
+ const tools = generateMcpTools(store);
8573
+ const updateTag = tools.find((t) => t.name === "update-tag")!;
8574
+ await updateTag.execute({
8575
+ tag: "meeting",
8576
+ fields: { meeting_date: { type: "date", indexed: true } },
8577
+ });
8578
+
8579
+ const projection = buildVaultProjection(db);
8580
+ const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
8581
+ expect(byName.meeting_date).toBeTruthy();
8582
+ expect(byName.meeting_date.type).toBe("date");
8583
+ expect(byName.meeting_date.tags).toEqual(["meeting"]);
8584
+ });
8585
+
8131
8586
  it("includes the static query-hint catalog", async () => {
8132
8587
  const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
8133
8588
  const projection = buildVaultProjection(db);
@@ -67,13 +67,16 @@ describe("indexed-fields: module", () => {
67
67
  expect(() => validateFieldName("'; DROP TABLE notes; --")).toThrow(IndexedFieldError);
68
68
  });
69
69
 
70
- it("TYPE_MAP covers string/integer/boolean/reference", () => {
70
+ it("TYPE_MAP covers string/integer/boolean/reference/date", () => {
71
71
  expect(TYPE_MAP.string).toBe("TEXT");
72
72
  expect(TYPE_MAP.integer).toBe("INTEGER");
73
73
  expect(TYPE_MAP.boolean).toBe("INTEGER");
74
74
  // vault#typed-reference-field: `reference` stores like `string` — the
75
75
  // metadata VALUE (an id/path/title) is what's indexed here.
76
76
  expect(TYPE_MAP.reference).toBe("TEXT");
77
+ // vault#date-field-type: `date` also stores like `string` — an ISO-8601
78
+ // string, which sorts correctly under a plain TEXT comparison.
79
+ expect(TYPE_MAP.date).toBe("TEXT");
77
80
  });
78
81
 
79
82
  it("declareField creates column + index on first declaration", () => {
@@ -212,12 +215,15 @@ describe("update-tag: indexed flag", () => {
212
215
  });
213
216
 
214
217
  it("unsupported field type for indexing throws", async () => {
218
+ // vault#date-field-type: `date` JOINED the indexable subset (stores TEXT,
219
+ // same as string/reference) — `number` (a float) is the current example
220
+ // of a recognized-but-unindexable type; see TYPE_MAP.
215
221
  expect(() =>
216
222
  findTool("update-tag").execute({
217
223
  tag: "project",
218
- fields: { weird: { type: "date", indexed: true } },
224
+ fields: { weird: { type: "number", indexed: true } },
219
225
  }),
220
- ).toThrow(/unsupported type "date"/);
226
+ ).toThrow(/unsupported type "number"/);
221
227
  });
222
228
 
223
229
  it("invalid field name for indexing throws", async () => {
@@ -27,13 +27,21 @@ export type SqliteType = "TEXT" | "INTEGER";
27
27
  // `string` — because the metadata VALUE (an id/path/title) is what's
28
28
  // indexed here; the resolved graph link is a separate concern maintained by
29
29
  // `core/src/store.ts`'s write path. See tag-schemas.ts's `VALID_FIELD_TYPES`.
30
- export type FieldType = "string" | "integer" | "boolean" | "reference";
30
+ // `date` is also stored as TEXT: an indexed `date` field holds an ISO-8601
31
+ // string (validated by `tag-schemas.ts`'s `defaultMatchesType` /
32
+ // `schema-defaults.ts`'s `valueMatchesType`), and ISO-8601 strings are
33
+ // lexicographically comparable — the same reason TEXT-stored `updated_at`
34
+ // range queries already work — so `gt`/`gte`/`lt`/`lte`, `date_filter`, and
35
+ // `order_by` all fall out of the generic TEXT-column machinery with no
36
+ // type-specific SQL.
37
+ export type FieldType = "string" | "integer" | "boolean" | "reference" | "date";
31
38
 
32
39
  export const TYPE_MAP: Record<FieldType, SqliteType> = {
33
40
  string: "TEXT",
34
41
  integer: "INTEGER",
35
42
  boolean: "INTEGER",
36
43
  reference: "TEXT",
44
+ date: "TEXT",
37
45
  };
38
46
 
39
47
  export interface IndexedField {
package/core/src/mcp.ts CHANGED
@@ -1355,6 +1355,12 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
1355
1355
  ...updates,
1356
1356
  actor: writeActor,
1357
1357
  via: writeVia,
1358
+ // `tagsForSchemaResolution` (vault#date-field-type review round
1359
+ // 2) — same bug/fix as the update-note handler: `store.tagNote`
1360
+ // below runs AFTER this UPDATE, so without the PROJECTED tag
1361
+ // set here, `date`-field normalization inside store.updateNote
1362
+ // would miss a field newly declared by an incoming tag.
1363
+ tagsForSchemaResolution: [...projectedTags],
1358
1364
  });
1359
1365
  }
1360
1366
 
@@ -2070,10 +2076,18 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2070
2076
  // --- Strict-schema gate (vault#299 Part A) ---
2071
2077
  // Validate the PROSPECTIVE shape (final tags + merged metadata,
2072
2078
  // including a state_transition's `to`) before the write so a
2073
- // rejection leaves the note untouched.
2079
+ // rejection leaves the note untouched. `projectedTags` is hoisted
2080
+ // out of this block (not just used here) — the `store.updateNote`
2081
+ // call below also needs it, as `tagsForSchemaResolution`, so that
2082
+ // `date`-field normalization sees a tag ADDED in this SAME call
2083
+ // (vault#date-field-type review round 2 — the actual `store.tagNote`
2084
+ // mutation happens AFTER the core UPDATE, so without this the
2085
+ // schema resolution inside `store.updateNote` would still be
2086
+ // looking at the note's stale pre-write tag set).
2087
+ let projectedTags: Set<string>;
2074
2088
  {
2075
2089
  const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
2076
- const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
2090
+ projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
2077
2091
  for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
2078
2092
  const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
2079
2093
  const projectedMeta = stItem !== undefined
@@ -2117,6 +2131,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2117
2131
  // updated_at on a no-op).
2118
2132
  updates.actor = writeActor;
2119
2133
  updates.via = writeVia;
2134
+ // `tagsForSchemaResolution` (vault#date-field-type review round
2135
+ // 2) — the PROJECTED final tag set, so `date`-field
2136
+ // normalization inside store.updateNote sees a tag ADDED in
2137
+ // this same call, not just the note's pre-write tags. See the
2138
+ // comment on `projectedTags`'s declaration above.
2139
+ updates.tagsForSchemaResolution = [...projectedTags];
2120
2140
  // store.updateNote routes through noteOps.updateNote, which runs
2121
2141
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
2122
2142
  // throws ConflictError on mismatch. No mutations have happened
@@ -2380,11 +2400,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2380
2400
  additionalProperties: {
2381
2401
  type: "object",
2382
2402
  properties: {
2383
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference — all seven are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`." },
2403
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference, date — all eight are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference/date are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`. `date` stores/validates exactly like `string`, but the value must be an ISO-8601 date (`2026-07-09`) or full timestamp (`2026-07-09T00:00:00.000Z`) — an unparseable value is a type_mismatch (advisory) or a rejected write (`strict: true` / `indexed: true`), same treatment as any other type mismatch. A full timestamp carrying an explicit `±HH:MM` offset is normalized to canonical UTC (`Z`-suffixed) on write — the offset is accepted, but not persisted verbatim — so indexed `date` fields sort/filter correctly under the TEXT comparison `gt`/`gte`/`lt`/`lte`/`date_filter`/`order_by` all use; a bare date (no time component) is left as-is." },
2384
2404
  description: { type: "string" },
2385
2405
  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." },
2386
2406
  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." },
2387
- 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/reference are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
2407
+ 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/reference/date are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
2388
2408
  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)." },
2389
2409
  required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
2390
2410
  cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },