@openparachute/vault 0.6.4-rc.2 → 0.6.4-rc.3

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.
@@ -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.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -215,6 +215,17 @@ export interface TriggerWhen {
215
215
  missing_metadata?: string[];
216
216
  /** Note.metadata must have ALL of these keys set (non-null). */
217
217
  has_metadata?: string[];
218
+ /**
219
+ * Value-matched metadata predicate (vault#299 Part B). A map of
220
+ * field → operator-object, evaluated against the note's live metadata with
221
+ * the SAME operators as `query-notes` (eq/ne/gt/gte/lt/lte/in/not_in/exists)
222
+ * via the shared `matchesOperator` engine. ALL entries must match (AND).
223
+ * Lets a trigger fire only on a specific transition — e.g.
224
+ * `metadata: { state: { eq: "published" } }` fires when a note reaches
225
+ * `published`, not on every edit. Combines with the presence/tag/content
226
+ * filters above (all must hold).
227
+ */
228
+ metadata?: Record<string, Record<string, unknown>>;
218
229
  }
219
230
 
220
231
  /**
package/src/mcp-http.ts CHANGED
@@ -151,6 +151,11 @@ async function handleMcp(
151
151
  note_path?: string | null;
152
152
  current_updated_at?: string | null;
153
153
  expected_updated_at?: string;
154
+ field?: string;
155
+ expected_from?: unknown;
156
+ to?: unknown;
157
+ current?: unknown;
158
+ violations?: unknown;
154
159
  };
155
160
  if (e?.code === "CONFLICT") {
156
161
  throw new McpError(ErrorCode.InvalidRequest, message, {
@@ -161,6 +166,28 @@ async function handleMcp(
161
166
  note_id: e.note_id,
162
167
  });
163
168
  }
169
+ // State-transition compare-and-set conflict (vault#299 Part B) — a
170
+ // DISTINCT vocabulary from `conflict` (settled lead #3): the value
171
+ // didn't match, not the updated_at token.
172
+ if (e?.code === "TRANSITION_CONFLICT") {
173
+ throw new McpError(ErrorCode.InvalidRequest, message, {
174
+ error_type: "transition_conflict",
175
+ note_id: e.note_id,
176
+ path: e.note_path ?? null,
177
+ field: e.field,
178
+ expected_from: e.expected_from,
179
+ to: e.to,
180
+ current: e.current ?? null,
181
+ });
182
+ }
183
+ // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
184
+ // per-field violations (settled lead #1).
185
+ if (e?.code === "SCHEMA_VALIDATION") {
186
+ throw new McpError(ErrorCode.InvalidParams, message, {
187
+ error_type: "schema_validation",
188
+ violations: e.violations ?? [],
189
+ });
190
+ }
164
191
  if (e?.code === "PRECONDITION_REQUIRED") {
165
192
  throw new McpError(ErrorCode.InvalidParams, message, {
166
193
  error_type: "precondition_required",
package/src/mcp-tools.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
- import type { McpToolDef } from "../core/src/mcp.ts";
9
+ import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
10
  import { getNoteTags } from "../core/src/notes.ts";
11
11
  import type { Note } from "../core/src/types.ts";
12
12
  import {
@@ -16,7 +16,7 @@ import {
16
16
  } from "../core/src/vault-projection.ts";
17
17
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
18
18
  import { getVaultStore } from "./vault-store.ts";
19
- import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
19
+ import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
20
20
  import type { AuthResult } from "./auth.ts";
21
21
  import {
22
22
  expandTokenTagScope,
@@ -177,13 +177,25 @@ export function generateScopedMcpTools(
177
177
  ? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
178
178
  : undefined;
179
179
 
180
+ // Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
181
+ // strict-schema enforcement and logs every bypassed write. Orthogonal to
182
+ // read/write/admin — an admin token does NOT bypass unless it also holds
183
+ // `migrate`. `onStrictBypass` writes the same structured log line the REST
184
+ // path uses (the audit-log table, #300, is deferred).
185
+ const strictBypass = auth ? hasMigrateScopeForVault(auth.scopes, vaultName) : false;
186
+ const onStrictBypass: GenerateMcpToolsOpts["onStrictBypass"] = strictBypass
187
+ ? (info) => logStrictBypass(info)
188
+ : undefined;
189
+
180
190
  const tools = generateMcpTools(
181
191
  store,
182
- expandVisibility || nearTraversable || writeContext
192
+ expandVisibility || nearTraversable || writeContext || strictBypass
183
193
  ? {
184
194
  ...(expandVisibility ? { expandVisibility } : {}),
185
195
  ...(nearTraversable ? { nearTraversable } : {}),
186
196
  ...(writeContext ? { writeContext } : {}),
197
+ ...(strictBypass ? { strictBypass } : {}),
198
+ ...(onStrictBypass ? { onStrictBypass } : {}),
187
199
  }
188
200
  : undefined,
189
201
  );
package/src/routes.ts CHANGED
@@ -21,7 +21,9 @@ import {
21
21
  contentRangeRequiresContent,
22
22
  type ContentRange,
23
23
  } from "../core/src/content-range.ts";
24
- import { attachValidationStatus } from "../core/src/mcp.ts";
24
+ import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
25
+ import type { ValidationWarning } from "../core/src/schema-defaults.ts";
26
+ import { logStrictBypass } from "./scopes.ts";
25
27
  import * as linkOps from "../core/src/links.ts";
26
28
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
27
29
  import {
@@ -51,9 +53,45 @@ const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
51
53
  * `store.createNote` / `store.updateNote`. Both null for paths without an auth
52
54
  * context (the no-op default). See `WriteContext` in core/src/notes.ts.
53
55
  */
54
- export type WriteCtx = { actor: string | null; via: string | null };
56
+ export type WriteCtx = {
57
+ actor: string | null;
58
+ via: string | null;
59
+ /**
60
+ * Migration-bypass (vault#299). True when the caller holds `vault:migrate`
61
+ * — strict-schema enforcement is skipped and the bypass is logged. Defaults
62
+ * to false (full enforcement) for every normal write.
63
+ */
64
+ bypassStrict?: boolean;
65
+ };
55
66
 
56
67
  const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
68
+
69
+ /**
70
+ * Run the shared strict-schema gate for a REST write (vault#299). Mirrors the
71
+ * MCP path's `enforceStrict` closure: enforce unless the caller holds the
72
+ * migration-bypass scope, and log every bypassed write to the daemon's
73
+ * structured log (the audit-log table, #300, is deferred). Throws
74
+ * `SchemaValidationError` (caught by the route's catch → 422) when not bypassed.
75
+ */
76
+ function gateStrictWrite(
77
+ store: Store,
78
+ writeCtx: WriteCtx,
79
+ shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
80
+ ): void {
81
+ enforceStrictWrite(store, shape, {
82
+ bypass: writeCtx.bypassStrict === true,
83
+ onBypass: (violations: ValidationWarning[]) => {
84
+ logStrictBypass({
85
+ actor: writeCtx.actor,
86
+ via: writeCtx.via,
87
+ path: shape.path ?? null,
88
+ tags: shape.tags,
89
+ violations,
90
+ });
91
+ },
92
+ });
93
+ }
94
+
57
95
  import {
58
96
  expandContent,
59
97
  DEFAULT_EXPAND_DEPTH,
@@ -1121,6 +1159,13 @@ async function handleNotesInner(
1121
1159
  const extension = item.extension !== undefined
1122
1160
  ? validateExtension(item.extension)
1123
1161
  : undefined;
1162
+ // Strict-schema gate (vault#299) — reject before any write so a
1163
+ // mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
1164
+ gateStrictWrite(store, writeCtx, {
1165
+ path: item.path,
1166
+ tags: item.tags,
1167
+ metadata: item.metadata,
1168
+ });
1124
1169
  const note = await store.createNote(item.content ?? "", {
1125
1170
  id: item.id,
1126
1171
  path: item.path,
@@ -1153,6 +1198,13 @@ async function handleNotesInner(
1153
1198
  409,
1154
1199
  );
1155
1200
  }
1201
+ // Strict-schema rejection (vault#299 Part A) on create — 422.
1202
+ if (e && e.code === "SCHEMA_VALIDATION") {
1203
+ return json(
1204
+ { error_type: "schema_validation", error: "schema_validation", violations: e.violations ?? [], message: e.message },
1205
+ 422,
1206
+ );
1207
+ }
1156
1208
  if (e && e.code === "INVALID_EXTENSION") {
1157
1209
  return json(
1158
1210
  { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
@@ -1410,6 +1462,12 @@ async function handleNotesInner(
1410
1462
  via: writeCtx.via,
1411
1463
  };
1412
1464
  const content = (body.content as string | undefined) ?? "";
1465
+ // Strict-schema gate (vault#299) — if_missing:"create" is a create.
1466
+ gateStrictWrite(store, writeCtx, {
1467
+ path: createOpts.path ?? undefined,
1468
+ tags: createOpts.tags,
1469
+ metadata: createOpts.metadata,
1470
+ });
1413
1471
  const created = await store.createNote(content, createOpts);
1414
1472
  if (tagsArr.length > 0) {
1415
1473
  await applySchemaDefaults(store, db, [created.id], tagsArr);
@@ -1499,7 +1557,19 @@ async function handleNotesInner(
1499
1557
  && body.createdAt === undefined
1500
1558
  && body.tags === undefined
1501
1559
  && body.links === undefined;
1502
- if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
1560
+ // A state_transition is itself a compare-and-set precondition (vault#299
1561
+ // Part B) — a transition-only PATCH needs no if_updated_at/force.
1562
+ const isTransitionOnly = body.state_transition !== undefined
1563
+ && !hasContent
1564
+ && !hasAppendPrepend
1565
+ && !hasContentEdit
1566
+ && body.path === undefined
1567
+ && body.metadata === undefined
1568
+ && body.created_at === undefined
1569
+ && body.createdAt === undefined
1570
+ && body.tags === undefined
1571
+ && body.links === undefined;
1572
+ if (!isAppendOnly && !isTransitionOnly && body.if_updated_at === undefined && body.force !== true) {
1503
1573
  return json(
1504
1574
  {
1505
1575
  error_type: "precondition_required",
@@ -1591,6 +1661,33 @@ async function handleNotesInner(
1591
1661
  if (body.if_updated_at !== undefined) {
1592
1662
  updates.if_updated_at = body.if_updated_at;
1593
1663
  }
1664
+ // Compare-and-set state transition (vault#299 Part B). Combinable with
1665
+ // other field updates; folds into the same atomic UPDATE.
1666
+ const stBody = body.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1667
+ if (stBody !== undefined) {
1668
+ if (typeof stBody.field !== "string" || stBody.field.length === 0) {
1669
+ return json(
1670
+ { error: "bad_request", message: "`state_transition.field` must be a non-empty string." },
1671
+ 400,
1672
+ );
1673
+ }
1674
+ updates.state_transition = { field: stBody.field, from: stBody.from, to: stBody.to };
1675
+ }
1676
+
1677
+ // --- Strict-schema gate (vault#299 Part A) ---
1678
+ // Validate the PROSPECTIVE shape (final tags + merged metadata, incl. a
1679
+ // state_transition's `to`) before the write so a rejection leaves the
1680
+ // note untouched. Throws SchemaValidationError → 422 in the catch.
1681
+ {
1682
+ const removeSet = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
1683
+ const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
1684
+ for (const t of (body.tags?.add as string[] | undefined) ?? []) projectedTags.add(t);
1685
+ const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
1686
+ const projectedMeta = stBody !== undefined
1687
+ ? { ...baseMeta, [stBody.field as string]: stBody.to }
1688
+ : baseMeta;
1689
+ gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1690
+ }
1594
1691
 
1595
1692
  if (Object.keys(updates).length > 0) {
1596
1693
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
@@ -1690,6 +1787,39 @@ async function handleNotesInner(
1690
1787
  409,
1691
1788
  );
1692
1789
  }
1790
+ // State-transition compare-and-set conflict (vault#299 Part B). A
1791
+ // DISTINCT error vocabulary from `conflict` (settled lead #3): the
1792
+ // field VALUE didn't match `from`, not the updated_at token. 409.
1793
+ if (e && e.code === "TRANSITION_CONFLICT") {
1794
+ return json(
1795
+ {
1796
+ error_type: "transition_conflict",
1797
+ error: "transition_conflict",
1798
+ note_id: e.note_id,
1799
+ path: e.note_path ?? null,
1800
+ field: e.field,
1801
+ expected_from: e.expected_from,
1802
+ to: e.to,
1803
+ current: e.current ?? null,
1804
+ message: e.message,
1805
+ },
1806
+ 409,
1807
+ );
1808
+ }
1809
+ // Strict-schema rejection (vault#299 Part A). One error carrying ALL
1810
+ // per-field violations (settled lead #1). 422 Unprocessable Entity —
1811
+ // the note exists / request is well-formed but violates the contract.
1812
+ if (e && e.code === "SCHEMA_VALIDATION") {
1813
+ return json(
1814
+ {
1815
+ error_type: "schema_validation",
1816
+ error: "schema_validation",
1817
+ violations: e.violations ?? [],
1818
+ message: e.message,
1819
+ },
1820
+ 422,
1821
+ );
1822
+ }
1693
1823
  // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
1694
1824
  if (e && e.code === "PATH_CONFLICT") {
1695
1825
  return json(
package/src/routing.ts CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  authenticateGlobalRequest,
53
53
  extractApiKey,
54
54
  } from "./auth.ts";
55
- import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
55
+ import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
56
56
  import { getVaultStore } from "./vault-store.ts";
57
57
  import { handleScopedMcp } from "./mcp-http.ts";
58
58
  import {
@@ -818,7 +818,15 @@ export async function route(
818
818
  // `operator` for the env-var bearer). The REST surface IS the `api` channel,
819
819
  // so no refinement is needed here — the base via stands (the MCP handler is
820
820
  // the one that refines to `mcp`). Threaded only into the write handler.
821
- const writeCtx: WriteCtx = { actor: auth.actor, via: auth.via };
821
+ // Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
822
+ // notes that violate `strict:true` field constraints (for backfill /
823
+ // migration). Every bypassed write is logged. Orthogonal to read/write/admin
824
+ // — an admin token does NOT bypass unless it also holds `migrate`.
825
+ const writeCtx: WriteCtx = {
826
+ actor: auth.actor,
827
+ via: auth.via,
828
+ bypassStrict: hasMigrateScopeForVault(auth.scopes, vaultName),
829
+ };
822
830
 
823
831
  if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
824
832
  // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live