@openparachute/vault 0.6.4-rc.2 → 0.6.4-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/notes.ts CHANGED
@@ -208,6 +208,50 @@ export class ConflictError extends Error {
208
208
  }
209
209
  }
210
210
 
211
+ /**
212
+ * Thrown by `updateNote` when a `state_transition` precondition fails: the
213
+ * named metadata field's current value does not equal `from` (a missing field
214
+ * counts as a mismatch). Distinct from `ConflictError` (vault#299 settled lead
215
+ * #3) — a state-transition conflict is about a VALUE not matching, not a
216
+ * stale `updated_at` token, so it carries its own `transition_conflict` code
217
+ * and the from/to/current triple rather than the if_updated_at vocabulary.
218
+ *
219
+ * The check + set is one atomic conditional UPDATE (`... WHERE
220
+ * json_extract(metadata,'$.field') IS ?`), so two racing transitioners can't
221
+ * both observe `from` and both commit — exactly one wins, the other gets this.
222
+ */
223
+ export class TransitionConflictError extends Error {
224
+ code = "TRANSITION_CONFLICT" as const;
225
+ note_id: string;
226
+ note_path: string | null;
227
+ field: string;
228
+ expected_from: unknown;
229
+ to: unknown;
230
+ current: unknown;
231
+
232
+ constructor(
233
+ noteId: string,
234
+ notePath: string | null,
235
+ field: string,
236
+ from: unknown,
237
+ to: unknown,
238
+ current: unknown,
239
+ ) {
240
+ super(
241
+ `transition_conflict: note "${noteId}" field "${field}" is ${JSON.stringify(
242
+ current ?? null,
243
+ )}, expected ${JSON.stringify(from)} to transition to ${JSON.stringify(to)}`,
244
+ );
245
+ this.name = "TransitionConflictError";
246
+ this.note_id = noteId;
247
+ this.note_path = notePath;
248
+ this.field = field;
249
+ this.expected_from = from;
250
+ this.to = to;
251
+ this.current = current;
252
+ }
253
+ }
254
+
211
255
  /**
212
256
  * Thrown by `createNote` / `updateNote` when the requested path is already
213
257
  * taken by another note. Surfaces as 409 at the HTTP layer so clients can
@@ -375,6 +419,18 @@ export function updateNote(
375
419
  * note still exists, a `ConflictError` is thrown.
376
420
  */
377
421
  if_updated_at?: string;
422
+ /**
423
+ * Compare-and-set state transition (vault#299 Part B). Atomically: if the
424
+ * named metadata field currently equals `from`, set it to `to` and commit;
425
+ * otherwise throw `TransitionConflictError` (a missing field is a
426
+ * conflict). The check rides the same conditional UPDATE — an additional
427
+ * `AND json_extract(metadata,'$.<field>') IS ?` clause — so two racing
428
+ * transitioners can't both pass. Combinable with `metadata` (other field
429
+ * updates merge alongside the transition) and `if_updated_at` (both
430
+ * preconditions must hold). `from`/`to` are JSON scalars (string / number /
431
+ * boolean / null).
432
+ */
433
+ state_transition?: { field: string; from: unknown; to: unknown };
378
434
  },
379
435
  ): Note {
380
436
  if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
@@ -463,16 +519,37 @@ export function updateNote(
463
519
  sets.push("extension = ?");
464
520
  values.push(updates.extension);
465
521
  }
522
+ // State-transition (vault#299 Part B). The `to` value must land in the
523
+ // metadata field. Two sub-cases keep the metadata write consistent:
524
+ // - caller ALSO passed `metadata` (the MCP/REST layer merges before
525
+ // calling): fold `to` into that object so the single `metadata = ?`
526
+ // SET carries it. The transition value wins over a merged value for
527
+ // the same field — the transition is the authoritative state write.
528
+ // - caller passed NO `metadata`: use SQL `json_set` so the field is
529
+ // updated in place without a read-merge-write (keeps it atomic).
530
+ // The atomic GUARD (current value == from) is appended to the WHERE below.
531
+ const st = updates.state_transition;
532
+ if (st !== undefined && updates.metadata !== undefined) {
533
+ (updates.metadata as Record<string, unknown>)[st.field] = st.to;
534
+ }
466
535
  if (updates.metadata !== undefined) {
467
536
  sets.push("metadata = ?");
468
537
  values.push(JSON.stringify(updates.metadata));
538
+ } else if (st !== undefined) {
539
+ // No metadata payload — set the single field via json_set. The path is
540
+ // bound as a parameter (mirrors the json_extract pattern elsewhere); the
541
+ // value is bound as a JSON-typed argument via `json(?)` so booleans /
542
+ // numbers / null land as their JSON types, not stringified text.
543
+ sets.push(`metadata = json_set(COALESCE(metadata, '{}'), ?, json(?))`);
544
+ values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.to));
469
545
  }
470
546
  if (updates.created_at !== undefined) {
471
547
  sets.push("created_at = ?");
472
548
  values.push(updates.created_at);
473
549
  }
474
550
 
475
- // No-op: no SET fields. If a caller still passed `if_updated_at`, we
551
+ // No-op: no SET fields. If a caller still passed `if_updated_at` (and no
552
+ // state_transition — that always pushes a metadata SET above), we
476
553
  // need to validate the precondition; a conditional UPDATE that sets
477
554
  // updated_at to itself does exactly that atomically — even a no-net-
478
555
  // change UPDATE takes the write lock in WAL mode, so it still serializes
@@ -497,10 +574,23 @@ export function updateNote(
497
574
  sql += " AND updated_at IS ?";
498
575
  values.push(updates.if_updated_at);
499
576
  }
577
+ // State-transition guard (vault#299 Part B): the atomic compare. The UPDATE
578
+ // only fires when the stored field currently equals `from`; otherwise zero
579
+ // rows match and we raise TransitionConflictError below. `IS` (not `=`) so
580
+ // a `from: null` correctly matches a stored JSON null / missing field, and
581
+ // a non-null `from` never matches a NULL/missing field (= conflict).
582
+ if (st !== undefined) {
583
+ sql += ` AND json_extract(metadata, ?) IS json_extract(json(?), '$')`;
584
+ values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.from));
585
+ }
500
586
 
587
+ // A value-conditional WHERE (if_updated_at OR state_transition) needs
588
+ // RETURNING to distinguish "matched + updated" from "no match" — `.changes`
589
+ // is unreliable inside transactions (vault#261).
590
+ const conditional = updates.if_updated_at !== undefined || st !== undefined;
501
591
  let matched: { id: string } | null = null;
502
592
  try {
503
- if (updates.if_updated_at !== undefined) {
593
+ if (conditional) {
504
594
  matched = db.prepare(`${sql} RETURNING id`).get(...values) as
505
595
  | { id: string }
506
596
  | null;
@@ -520,13 +610,47 @@ export function updateNote(
520
610
  throw err;
521
611
  }
522
612
 
523
- if (updates.if_updated_at !== undefined && matched === null) {
524
- throwConflictOrMissing(db, id, updates.if_updated_at);
613
+ if (conditional && matched === null) {
614
+ // No row matched. Disambiguate the cause, checking the if_updated_at
615
+ // precondition first (it's the cheaper, pre-existing contract), then the
616
+ // state-transition value, then not-found.
617
+ const row = db.prepare("SELECT updated_at, path, metadata FROM notes WHERE id = ?").get(id) as
618
+ | { updated_at: string | null; path: string | null; metadata: string | null }
619
+ | null;
620
+ if (!row) throw new Error(`Note not found: "${id}"`);
621
+ if (updates.if_updated_at !== undefined && row.updated_at !== updates.if_updated_at) {
622
+ throw new ConflictError(id, row.path, row.updated_at, updates.if_updated_at);
623
+ }
624
+ if (st !== undefined) {
625
+ // if_updated_at (if any) matched, so the mismatch is the transition.
626
+ let current: unknown;
627
+ try {
628
+ const meta = row.metadata ? JSON.parse(row.metadata) : {};
629
+ current = (meta as Record<string, unknown>)[st.field];
630
+ } catch {
631
+ current = undefined;
632
+ }
633
+ throw new TransitionConflictError(id, row.path, st.field, st.from, st.to, current);
634
+ }
635
+ // if_updated_at-only path that fell through (timestamp matched but row
636
+ // vanished mid-flight): preserve the prior contract.
637
+ throwConflictOrMissing(db, id, updates.if_updated_at!);
525
638
  }
526
639
 
527
640
  return getNote(db, id)!;
528
641
  }
529
642
 
643
+ /**
644
+ * Build the dotted JSON-path key fragment for a metadata field name. Field
645
+ * names that contain characters JSON-path treats specially (`.`, `[`, `"`,
646
+ * etc.) are double-quoted; simple identifiers pass through bare. Mirrors the
647
+ * SQLite JSON1 path grammar.
648
+ */
649
+ function jsonPathKey(field: string): string {
650
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) return field;
651
+ return `"${field.replace(/"/g, '\\"')}"`;
652
+ }
653
+
530
654
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
531
655
  const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
532
656
  | { updated_at: string | null; path: string | null }
@@ -188,3 +188,120 @@ export function buildOperatorClause(
188
188
  params,
189
189
  };
190
190
  }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // In-memory operator evaluation (vault#299 — value-matched triggers)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Evaluate an operator object against a single in-memory value — the
198
+ * non-SQL twin of {@link buildOperatorClause}. The trigger engine fires on a
199
+ * live `note.metadata` object (not a DB row), so it can't route through the
200
+ * indexed generated columns; this gives it the SAME operator vocabulary +
201
+ * semantics without re-implementing them. An object with multiple operators
202
+ * is AND-composed (`{ gt: 5, lt: 10 }`), matching the SQL clause builder.
203
+ *
204
+ * `value` is the field's current value (`undefined` when the field is
205
+ * absent). Semantics mirror the SQL builder, including the NULL/missing
206
+ * handling: `ne`/`not_in` treat an absent/null value as "matches" (the SQL
207
+ * `(col IS NULL OR ...)` shape); `eq: null` matches an absent/null value.
208
+ *
209
+ * Throws `QueryError` on an unknown operator or a malformed operand — the
210
+ * trigger validator surfaces it as a 400 at registration time.
211
+ */
212
+ export function matchesOperator(
213
+ field: string,
214
+ value: unknown,
215
+ opObj: Record<string, unknown>,
216
+ ): boolean {
217
+ validateOperatorObject(field, opObj);
218
+ const absent = value === undefined || value === null;
219
+
220
+ for (const [op, operand] of Object.entries(opObj)) {
221
+ switch (op as QueryOp) {
222
+ case "eq":
223
+ if (operand === null) {
224
+ if (!absent) return false;
225
+ } else if (absent || !looseEquals(value, operand)) {
226
+ return false;
227
+ }
228
+ break;
229
+ case "ne":
230
+ if (operand === null) {
231
+ if (absent) return false;
232
+ } else if (!absent && looseEquals(value, operand)) {
233
+ return false;
234
+ }
235
+ break;
236
+ case "gt":
237
+ case "gte":
238
+ case "lt":
239
+ case "lte": {
240
+ if (absent) return false;
241
+ const cmp = compareScalar(value, operand);
242
+ if (cmp === null) return false;
243
+ if (op === "gt" && !(cmp > 0)) return false;
244
+ if (op === "gte" && !(cmp >= 0)) return false;
245
+ if (op === "lt" && !(cmp < 0)) return false;
246
+ if (op === "lte" && !(cmp <= 0)) return false;
247
+ break;
248
+ }
249
+ case "in":
250
+ case "not_in": {
251
+ if (!Array.isArray(operand)) {
252
+ throw new QueryError(
253
+ `operator "${op}" on metadata field "${field}" expects an array`,
254
+ "INVALID_OPERATOR_VALUE",
255
+ );
256
+ }
257
+ const present = !absent && operand.some((o) => looseEquals(value, o));
258
+ if (op === "in" && !present) return false;
259
+ // not_in: absent/null matches (mirrors SQL `col IS NULL OR ...`).
260
+ if (op === "not_in" && present) return false;
261
+ break;
262
+ }
263
+ case "exists":
264
+ if (typeof operand !== "boolean") {
265
+ throw new QueryError(
266
+ `operator "exists" on metadata field "${field}" expects a boolean`,
267
+ "INVALID_OPERATOR_VALUE",
268
+ );
269
+ }
270
+ if (operand === !absent) break;
271
+ return false;
272
+ }
273
+ }
274
+ return true;
275
+ }
276
+
277
+ /**
278
+ * Loose scalar equality used by in-memory `eq`/`ne`/`in`. Matches SQLite's
279
+ * affinity-tolerant comparison closely enough for trigger value-matching:
280
+ * exact for same-type scalars; cross-type number/string compares by string
281
+ * form (`5` matches `"5"`), since metadata round-trips through JSON and a
282
+ * note may carry either shape.
283
+ */
284
+ function looseEquals(a: unknown, b: unknown): boolean {
285
+ if (a === b) return true;
286
+ if (
287
+ (typeof a === "number" || typeof a === "string") &&
288
+ (typeof b === "number" || typeof b === "string")
289
+ ) {
290
+ return String(a) === String(b);
291
+ }
292
+ return false;
293
+ }
294
+
295
+ /**
296
+ * Three-way comparison for ordered operators (gt/gte/lt/lte). Returns a
297
+ * negative/zero/positive number, or `null` when the operands aren't
298
+ * order-comparable (so the caller treats it as "no match"). Numbers compare
299
+ * numerically; strings lexicographically; mixed types don't compare.
300
+ */
301
+ function compareScalar(a: unknown, b: unknown): number | null {
302
+ if (typeof a === "number" && typeof b === "number") return a - b;
303
+ if (typeof a === "string" && typeof b === "string") {
304
+ return a < b ? -1 : a > b ? 1 : 0;
305
+ }
306
+ return null;
307
+ }
@@ -60,6 +60,33 @@ export interface SchemaField {
60
60
  type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
61
61
  enum?: string[];
62
62
  description?: string;
63
+ /**
64
+ * Strict enforcement opt-in (vault#299, Part A). Default `false` — when
65
+ * unset/false, ALL constraints on this field are advisory: violations
66
+ * surface as `validation_status` warnings and the write succeeds (the
67
+ * historical byte-identical behavior). When `true`, ALL declared
68
+ * constraints on this field (type + enum + required + cardinality) flip
69
+ * to hard write rejections, all-or-nothing per field (the Gitcoin team's
70
+ * call — "enum + required together, not enum alone"). Free-form fields
71
+ * (`notes`/`description`) on an otherwise-strict tag stay advisory by
72
+ * simply leaving `strict` off — strictness is per-field, not per-tag.
73
+ */
74
+ strict?: boolean;
75
+ /**
76
+ * Whether the field MUST be present (and non-null) on a note carrying this
77
+ * tag. Advisory under `strict:false` (a `required` violation surfaces as a
78
+ * `missing_required` warning); a hard rejection only when `strict:true`.
79
+ */
80
+ required?: boolean;
81
+ /**
82
+ * Cardinality constraint for the field's value. `"one"` (the implicit
83
+ * default) means a single scalar — an array value is a `cardinality`
84
+ * violation. `"many"` means the value must be an array. Advisory under
85
+ * `strict:false`; a hard rejection when `strict:true`. Distinct from the
86
+ * relationship cardinality vocabulary (that governs typed links, not
87
+ * metadata fields).
88
+ */
89
+ cardinality?: "one" | "many";
63
90
  }
64
91
 
65
92
  /**
@@ -83,19 +110,35 @@ export interface ValidationWarning {
83
110
  /**
84
111
  * `type_mismatch` — value's type contradicts the declared `type`.
85
112
  * `enum_mismatch` — string value not in the declared `enum`.
113
+ * `missing_required` — a `required` field is absent or null (vault#299).
114
+ * `cardinality_mismatch` — value's shape (scalar vs array) contradicts the
115
+ * declared `cardinality` (vault#299).
86
116
  * `schema_conflict` — two ancestors declared the same field with
87
117
  * different specs; first-in-walk wins, the loser surfaces here so the
88
118
  * operator can resolve the disagreement.
89
119
  */
90
- reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
120
+ reason:
121
+ | "type_mismatch"
122
+ | "enum_mismatch"
123
+ | "missing_required"
124
+ | "cardinality_mismatch"
125
+ | "schema_conflict";
91
126
  message: string;
92
127
  /**
93
128
  * `schema_conflict` only — the tag whose declaration was overridden. Set
94
- * when `reason === "schema_conflict"`; absent on type/enum mismatches.
129
+ * when `reason === "schema_conflict"`; absent on other reasons.
95
130
  * Surfaces structurally so agents don't have to regex `message` to find
96
131
  * the loser.
97
132
  */
98
133
  loser_schema?: string;
134
+ /**
135
+ * `true` when this violation comes from a `strict:true` field (vault#299).
136
+ * A strict violation is an ENFORCEMENT error — the write path rejects it.
137
+ * Absent/false for advisory warnings (the historical guidance behavior).
138
+ * Surfaced structurally so the write path can split "block" from "warn"
139
+ * without re-deriving the field's strict flag.
140
+ */
141
+ strict?: boolean;
99
142
  }
100
143
 
101
144
  export interface ValidationStatus {
@@ -126,6 +169,9 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
126
169
  if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
127
170
  if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
128
171
  if (typeof f.description === "string") field.description = f.description;
172
+ if (f.strict === true) field.strict = true;
173
+ if (f.required === true) field.required = true;
174
+ if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
129
175
  fields[k] = field;
130
176
  }
131
177
  return fields;
@@ -301,15 +347,23 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
301
347
  * Validate a note's metadata against the merged schema. Returns null when no
302
348
  * ancestor declares any fields (so the caller can omit `validation_status`
303
349
  * entirely). Otherwise returns the status with conflict warnings prepended,
304
- * followed by per-field type/enum mismatches.
350
+ * followed by per-field violations.
305
351
  *
306
352
  * Rules per merged field:
353
+ * - `required` declared and value absent/null → `missing_required`
307
354
  * - Present and `type` declared and value's type doesn't match → `type_mismatch`
308
355
  * - Present and `enum` declared and value not in enum → `enum_mismatch`
356
+ * - Present and `cardinality` declared and shape (scalar vs array) wrong
357
+ * → `cardinality_mismatch`
358
+ *
359
+ * Every violation carries `strict: true` iff its field declared `strict:true`
360
+ * (vault#299). The list itself is the SAME whether or not a field is strict —
361
+ * the difference is only the per-warning `strict` flag, which the write path
362
+ * uses to decide block-vs-warn. Under `strict:false` this is byte-identical to
363
+ * the historical advisory behavior PLUS the new `required`/`cardinality`
364
+ * advisory reasons (which fire for any field declaring those, strict or not).
309
365
  *
310
- * Fields not declared by any ancestor's schema are ignored entirely (this
311
- * isn't a "strict" validator — it's a guide). There is no `required` concept
312
- * (post-v17); declarations are advisory only.
366
+ * Fields not declared by any ancestor's schema are ignored entirely.
313
367
  */
314
368
  export function validateNote(
315
369
  resolved: ResolvedSchemas,
@@ -322,9 +376,25 @@ export function validateNote(
322
376
  const warnings: ValidationWarning[] = [...resolution.conflicts];
323
377
 
324
378
  for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
325
- if (!(fieldName in m)) continue;
326
- const value = m[fieldName];
327
- if (value === undefined || value === null) continue;
379
+ const strictFlag = spec.strict === true ? { strict: true } : {};
380
+ const present = fieldName in m;
381
+ const value = present ? m[fieldName] : undefined;
382
+ const absent = !present || value === undefined || value === null;
383
+
384
+ if (spec.required === true && absent) {
385
+ warnings.push({
386
+ field: fieldName,
387
+ schema: sourceTag,
388
+ reason: "missing_required",
389
+ message: `'${fieldName}' is required (tag '${sourceTag}')`,
390
+ ...strictFlag,
391
+ });
392
+ // A required field that's absent has no value to type/enum/cardinality
393
+ // check — the missing_required violation stands alone.
394
+ continue;
395
+ }
396
+
397
+ if (absent) continue;
328
398
 
329
399
  if (spec.type && !valueMatchesType(value, spec.type)) {
330
400
  warnings.push({
@@ -332,6 +402,7 @@ export function validateNote(
332
402
  schema: sourceTag,
333
403
  reason: "type_mismatch",
334
404
  message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
405
+ ...strictFlag,
335
406
  });
336
407
  }
337
408
 
@@ -341,9 +412,91 @@ export function validateNote(
341
412
  schema: sourceTag,
342
413
  reason: "enum_mismatch",
343
414
  message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
415
+ ...strictFlag,
344
416
  });
345
417
  }
418
+
419
+ if (spec.cardinality) {
420
+ const isArray = Array.isArray(value);
421
+ const wantMany = spec.cardinality === "many";
422
+ if (wantMany !== isArray) {
423
+ warnings.push({
424
+ field: fieldName,
425
+ schema: sourceTag,
426
+ reason: "cardinality_mismatch",
427
+ message: wantMany
428
+ ? `'${fieldName}' must be an array (cardinality 'many', tag '${sourceTag}')`
429
+ : `'${fieldName}' must be a single value, not an array (cardinality 'one', tag '${sourceTag}')`,
430
+ ...strictFlag,
431
+ });
432
+ }
433
+ }
346
434
  }
347
435
 
348
436
  return { schemas: resolution.effectiveTags, warnings };
349
437
  }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Strict enforcement (vault#299)
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Thrown by the write path when a note violates one or more `strict:true`
445
+ * field constraints. Carries ALL per-field violations in a single error (the
446
+ * settled design lead — one `SchemaValidationError`, not per-axis errors), so
447
+ * an agent sees the whole contract it broke in one response and can fix every
448
+ * field before retrying.
449
+ *
450
+ * Each entry is a `ValidationWarning` with `strict: true`. `code` is stable
451
+ * (`SCHEMA_VALIDATION`) for transport mapping (MCP error / HTTP 422).
452
+ */
453
+ export class SchemaValidationError extends Error {
454
+ code = "SCHEMA_VALIDATION" as const;
455
+ violations: ValidationWarning[];
456
+
457
+ constructor(violations: ValidationWarning[]) {
458
+ const summary = violations
459
+ .map((v) => `${v.field}: ${v.reason}`)
460
+ .join("; ");
461
+ super(`schema_validation: ${violations.length} strict field violation(s) — ${summary}`);
462
+ this.name = "SchemaValidationError";
463
+ this.violations = violations;
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Extract the enforcement-level (strict) subset of a validation status —
469
+ * the violations the write path must reject. Conflict warnings are advisory
470
+ * by nature (they describe operator schema disagreements, not note data) and
471
+ * are never enforced even when a field is strict. Returns `[]` when nothing
472
+ * strict was violated (the common case — the caller then proceeds with the
473
+ * write).
474
+ */
475
+ export function strictViolations(status: ValidationStatus | null): ValidationWarning[] {
476
+ if (!status) return [];
477
+ return status.warnings.filter((w) => w.strict === true && w.reason !== "schema_conflict");
478
+ }
479
+
480
+ /**
481
+ * Validate-and-enforce: run `validateNote`, and if any `strict:true` field is
482
+ * violated, throw a single `SchemaValidationError` carrying every violation.
483
+ * Returns the full advisory `ValidationStatus` (or null) on success so the
484
+ * caller can still surface advisory warnings on the response. `bypass: true`
485
+ * skips the throw entirely (migration-bypass scope) — the caller is
486
+ * responsible for logging the bypass.
487
+ *
488
+ * The write path calls this BEFORE persisting so a rejection leaves the note
489
+ * untouched.
490
+ */
491
+ export function enforceStrictSchema(
492
+ resolved: ResolvedSchemas,
493
+ note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
494
+ opts?: { bypass?: boolean },
495
+ ): { status: ValidationStatus | null; violations: ValidationWarning[] } {
496
+ const status = validateNote(resolved, note);
497
+ const violations = strictViolations(status);
498
+ if (violations.length > 0 && opts?.bypass !== true) {
499
+ throw new SchemaValidationError(violations);
500
+ }
501
+ return { status, violations };
502
+ }
@@ -30,6 +30,18 @@ export interface TagFieldSchema {
30
30
  // across declarers — all tags declaring this field must agree on both
31
31
  // `type` and `indexed`. See core/src/indexed-fields.ts for lifecycle.
32
32
  indexed?: boolean;
33
+ // Strict-enforcement opt-in (vault#299). Default false (advisory). When
34
+ // true, ALL declared constraints on this field (type + enum + required +
35
+ // cardinality) flip from validation_status warnings to hard write
36
+ // rejections — all-or-nothing per field. Stored verbatim in the `fields`
37
+ // JSON column; the resolver (schema-defaults.ts) interprets it.
38
+ strict?: boolean;
39
+ // The field must be present + non-null on a note carrying this tag.
40
+ // Advisory unless `strict:true`. vault#299.
41
+ required?: boolean;
42
+ // "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
43
+ // Distinct from relationship cardinality. vault#299.
44
+ cardinality?: "one" | "many";
33
45
  }
34
46
 
35
47
  /**
@@ -27,6 +27,12 @@ export interface StoredTriggerWhen {
27
27
  has_content?: boolean;
28
28
  missing_metadata?: string[];
29
29
  has_metadata?: string[];
30
+ /**
31
+ * Value-matched metadata predicate (vault#299 Part B). field →
32
+ * operator-object, evaluated with the shared query-operators engine. See
33
+ * src/config.ts:TriggerWhen.metadata.
34
+ */
35
+ metadata?: Record<string, Record<string, unknown>>;
30
36
  }
31
37
 
32
38
  /** Webhook auth — only the bearer-JWT path for now. */
package/core/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
4
  import type { TagExpandMode } from "./tag-hierarchy.js";
5
+ import type { ValidationStatus } from "./schema-defaults.js";
5
6
 
6
7
  // ---- Re-exports ----
7
8
 
@@ -420,17 +421,7 @@ export interface Store {
420
421
  // `_default` is the implicit universal parent. Returns null when no
421
422
  // ancestor declares any fields. The underlying resolver is in-memory after
422
423
  // the first lazy load.
423
- validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
424
- schemas: string[];
425
- warnings: {
426
- field: string;
427
- schema: string;
428
- reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
429
- message: string;
430
- /** Set only on `schema_conflict` — the tag whose declaration was overridden. */
431
- loser_schema?: string;
432
- }[];
433
- } | null;
424
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null;
434
425
 
435
426
  // Attachments
436
427
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
@@ -369,6 +369,25 @@ export function projectionToMarkdown(args: {
369
369
  lines.push(`- No indexed metadata fields.`);
370
370
  }
371
371
 
372
+ // Strict fields (vault#299): surface the enforced contract so an agent knows
373
+ // BEFORE writing which fields hard-reject on violation (not just warn).
374
+ // Derived from each schema tag's effective fields carrying `strict: true`.
375
+ const strictLines: string[] = [];
376
+ for (const t of projection.tags) {
377
+ const strictNames = Object.entries(t.effective_fields)
378
+ .filter(([, spec]) => (spec as { strict?: boolean }).strict === true)
379
+ .map(([name]) => name);
380
+ if (strictNames.length > 0) {
381
+ strictLines.push(` - #${t.name}: ${strictNames.join(", ")}`);
382
+ }
383
+ }
384
+ if (strictLines.length > 0) {
385
+ lines.push(
386
+ `- Strict fields (writes that violate these are REJECTED, not just warned — enum/type/required/cardinality enforced):`,
387
+ );
388
+ lines.push(...strictLines);
389
+ }
390
+
372
391
  lines.push("");
373
392
  lines.push("## Querying");
374
393
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.2",
3
+ "version": "0.6.4-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",