@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.
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +455 -0
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/mcp.ts +24 -4
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +176 -0
- package/core/src/store.ts +59 -3
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +2 -1
package/core/src/conformance.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
package/core/src/core.test.ts
CHANGED
|
@@ -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: "
|
|
224
|
+
fields: { weird: { type: "number", indexed: true } },
|
|
219
225
|
}),
|
|
220
|
-
).toThrow(/unsupported type "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`." },
|