@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/README.md +38 -1
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +128 -4
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +2 -11
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/cli.ts +164 -3
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +15 -3
- package/src/routes.ts +133 -3
- package/src/routing.ts +10 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
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
|
|
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 (
|
|
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 (
|
|
524
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
+
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -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("");
|