@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/src/cli.ts CHANGED
@@ -3345,11 +3345,17 @@ async function cmdExport(args: string[]) {
3345
3345
  */
3346
3346
  async function cmdSchema(args: string[] = []) {
3347
3347
  const sub = args[0];
3348
+ if (sub === "migrate-field") {
3349
+ await cmdSchemaMigrateField(args.slice(1));
3350
+ return;
3351
+ }
3348
3352
  if (sub !== "prune") {
3349
- console.error("Usage: parachute-vault schema prune [--vault <name>] [--dry-run|--apply|--yes]");
3353
+ console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
3350
3354
  console.error("\nSubcommands:");
3351
- console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
3352
- console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
3355
+ console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
3356
+ console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
3357
+ console.error(" migrate-field Evolve a tag-schema field across existing notes (rename/remap/set-default/retype).");
3358
+ console.error(" Dry-run by default; pass --apply (or --yes) to write. See `schema migrate-field --help`.");
3353
3359
  process.exit(1);
3354
3360
  }
3355
3361
 
@@ -3416,6 +3422,154 @@ async function cmdSchema(args: string[] = []) {
3416
3422
  }
3417
3423
  }
3418
3424
 
3425
+ /**
3426
+ * `parachute-vault schema migrate-field <tag> <field> --transform <spec>` —
3427
+ * evolve a tag-schema field's type / values across the existing notes that
3428
+ * carry the tag (vault#314). Dry-run by default — prints the count + a sample
3429
+ * of what WOULD change and writes nothing; `--apply` (alias `--yes`) executes.
3430
+ *
3431
+ * Transform specs (the built-in set — issue #314):
3432
+ * rename:<newkey> move the field's value to <newkey>, drop the old key
3433
+ * remap:<old>=<new>[,…] rewrite enum-style values (repeatable / comma-sep)
3434
+ * set-default:<value> fill the field where missing/null
3435
+ * set-default-invalid:<v> set <v> where missing/null OR currently schema-invalid
3436
+ * to_string | to_int | to_number | to_boolean retype the value
3437
+ *
3438
+ * The migration writes through `strict` enforcement (the migrate-bypass path,
3439
+ * vault#299) so a freshly-declared strict field that the back-catalog violates
3440
+ * doesn't block the rewrite, and every bypassed write is logged. Rewrites are
3441
+ * attributed `migrate` (last_updated_*); created_* is preserved.
3442
+ *
3443
+ * Atomic by default: if any note's value can't be converted (e.g. `to_int` on
3444
+ * "banana"), the apply aborts BEFORE writing anything and lists the offenders.
3445
+ * Pass `--continue-on-error` for best-effort (write the convertible, skip the rest).
3446
+ */
3447
+ async function cmdSchemaMigrateField(args: string[]) {
3448
+ const usageLine =
3449
+ "Usage: parachute-vault schema migrate-field <tag> <field> --transform <spec> [--vault <name>] [--apply|--yes] [--continue-on-error]";
3450
+ if (args[0] === "--help" || args[0] === "-h") {
3451
+ console.log(usageLine);
3452
+ console.log("\nTransform specs:");
3453
+ console.log(" rename:<newkey> Move the field's value to <newkey>, drop the old key.");
3454
+ console.log(" remap:<old>=<new>[,...] Rewrite enum-style values (repeatable, comma-separated).");
3455
+ console.log(" set-default:<value> Set <value> on notes where the field is missing/null.");
3456
+ console.log(" set-default-invalid:<v> Set <v> where missing/null OR the current value is schema-invalid.");
3457
+ console.log(" to_string | to_int | to_number | to_boolean Retype the field's value.");
3458
+ console.log("\nDry-run by default — prints the plan. Pass --apply (or --yes) to write.");
3459
+ return;
3460
+ }
3461
+
3462
+ let vaultName = "default";
3463
+ let apply = false;
3464
+ let continueOnError = false;
3465
+ let transformSpec: string | undefined;
3466
+ const positionals: string[] = [];
3467
+ for (let i = 0; i < args.length; i++) {
3468
+ const arg = args[i]!;
3469
+ if (arg === "--vault") {
3470
+ const v = args[++i];
3471
+ if (!v) { console.error("--vault requires a value."); process.exit(1); }
3472
+ vaultName = v;
3473
+ } else if (arg === "--transform") {
3474
+ const v = args[++i];
3475
+ if (!v) { console.error("--transform requires a value."); process.exit(1); }
3476
+ transformSpec = v;
3477
+ } else if (arg === "--apply" || arg === "--yes") {
3478
+ apply = true;
3479
+ } else if (arg === "--dry-run") {
3480
+ // default; accept as an explicit affirmative
3481
+ } else if (arg === "--continue-on-error") {
3482
+ continueOnError = true;
3483
+ } else if (arg.startsWith("--")) {
3484
+ console.error(`Unknown flag for \`schema migrate-field\`: ${arg}`);
3485
+ process.exit(1);
3486
+ } else {
3487
+ positionals.push(arg);
3488
+ }
3489
+ }
3490
+
3491
+ const [tag, field] = positionals;
3492
+ if (!tag || !field || !transformSpec) {
3493
+ console.error(usageLine);
3494
+ console.error("\n <tag> <field> and --transform <spec> are all required.");
3495
+ console.error(" Run `schema migrate-field --help` for the transform spec list.");
3496
+ process.exit(1);
3497
+ }
3498
+
3499
+ const { parseTransformSpec } = await import("../core/src/migrate-tag-field.ts");
3500
+ let transform;
3501
+ try {
3502
+ transform = parseTransformSpec(transformSpec);
3503
+ } catch (err) {
3504
+ console.error(`Invalid --transform "${transformSpec}": ${(err as Error).message}`);
3505
+ process.exit(1);
3506
+ }
3507
+
3508
+ const config = readVaultConfig(vaultName);
3509
+ if (!config) {
3510
+ console.error(`Vault "${vaultName}" not found.`);
3511
+ process.exit(1);
3512
+ }
3513
+
3514
+ const { getVaultStore } = await import("./vault-store.ts");
3515
+ const { migrateTagField } = await import("../core/src/migrate-tag-field.ts");
3516
+ const { logStrictBypass } = await import("./scopes.ts");
3517
+ const store = getVaultStore(vaultName);
3518
+
3519
+ const result = await migrateTagField(store, {
3520
+ tag,
3521
+ field,
3522
+ transform: transform!,
3523
+ apply,
3524
+ continueOnError,
3525
+ // The CLI is the operator path — it bypasses strict enforcement by nature
3526
+ // (writes go through the store, not the scope-gated transport). Log every
3527
+ // waived violation so the bypass is auditable, exactly as MW2 intends.
3528
+ onStrictBypass: (info) => logStrictBypass(info),
3529
+ });
3530
+
3531
+ const verb = result.applied ? "Migrated" : "Would migrate";
3532
+ console.log(
3533
+ `${verb} field "${result.field}" on #${result.tag} in vault "${vaultName}"${result.applied ? "" : " (dry-run)"}:`,
3534
+ );
3535
+ console.log(
3536
+ ` scanned ${result.scanned} · ${result.applied ? "changed" : "would change"} ${result.changed} · unchanged ${result.unchanged} · errored ${result.errored}`,
3537
+ );
3538
+
3539
+ if (result.sample.length > 0) {
3540
+ console.log(`\n ${result.applied ? "Changes" : "Sample of changes"} (${result.sample.length}${result.changed > result.sample.length ? ` of ${result.changed}` : ""}):`);
3541
+ for (const c of result.sample) {
3542
+ const ref = c.path ?? c.id;
3543
+ if (c.renamedTo) {
3544
+ console.log(` ${ref}: ${field} → ${c.renamedTo} = ${JSON.stringify(c.after)}`);
3545
+ } else {
3546
+ console.log(` ${ref}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
3547
+ }
3548
+ }
3549
+ }
3550
+
3551
+ if (result.errors.length > 0) {
3552
+ console.log(`\n Unconvertible (${result.errors.length}):`);
3553
+ for (const e of result.errors) {
3554
+ console.log(` ${e.path ?? e.id}: ${e.error}`);
3555
+ }
3556
+ if (apply && !result.applied) {
3557
+ console.log(
3558
+ "\n Aborted without writing — some notes can't be converted (atomic by default).",
3559
+ );
3560
+ console.log(" Fix the data, or re-run with --continue-on-error to migrate the rest.");
3561
+ process.exit(1);
3562
+ }
3563
+ }
3564
+
3565
+ if (!apply && result.changed > 0) {
3566
+ console.log("\n Re-run with --apply (or --yes) to write these changes.");
3567
+ }
3568
+ if (result.changed === 0 && result.errored === 0) {
3569
+ console.log("\n Nothing to migrate — the back-catalog already conforms.");
3570
+ }
3571
+ }
3572
+
3419
3573
  // ---------------------------------------------------------------------------
3420
3574
  // Export-watch glue. The git-shell + commit-message logic lives in
3421
3575
  // `./export-watch.ts` for unit-testability; cli.ts just wires it in.
@@ -3961,6 +4115,13 @@ Schema maintenance:
3961
4115
  parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
3962
4116
  fields keep their column; a drop loses only
3963
4117
  the index (data lives in notes.metadata).
4118
+ parachute-vault schema migrate-field <tag> <field> Evolve a tag-schema field across existing
4119
+ --transform <spec> notes (rename:<key>, remap:<old>=<new>,
4120
+ set-default:<v>, to_string/to_int/to_number/
4121
+ to_boolean). Dry-run by default — prints the
4122
+ plan; pass --apply (or --yes) to write.
4123
+ Writes through strict enforcement (migrate
4124
+ bypass). See \`schema migrate-field --help\`.
3964
4125
 
3965
4126
  ── Advanced / standalone ──────────────────────────────────────────────
3966
4127
 
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
@@ -9,10 +9,12 @@ import {
9
9
  SCOPE_READ,
10
10
  SCOPE_WRITE,
11
11
  SCOPE_ADMIN,
12
+ SCOPE_MIGRATE,
12
13
  parseScopes,
13
14
  parseScopeFlags,
14
15
  hasScope,
15
16
  hasScopeForVault,
17
+ hasMigrateScopeForVault,
16
18
  findBroadVaultScopes,
17
19
  scopeForMethod,
18
20
  verbForMethod,
@@ -20,6 +22,28 @@ import {
20
22
  serializeScopes,
21
23
  } from "./scopes.ts";
22
24
 
25
+ describe("hasMigrateScopeForVault — migration-bypass capability (vault#299)", () => {
26
+ test("broad vault:migrate satisfies any vault", () => {
27
+ expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "default")).toBe(true);
28
+ expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "anything")).toBe(true);
29
+ });
30
+
31
+ test("narrowed vault:<name>:migrate satisfies only that vault", () => {
32
+ expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "gitcoin")).toBe(true);
33
+ expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "default")).toBe(false);
34
+ });
35
+
36
+ test("admin does NOT imply migrate — orthogonal axis", () => {
37
+ expect(hasMigrateScopeForVault([SCOPE_ADMIN], "default")).toBe(false);
38
+ expect(hasMigrateScopeForVault(["vault:default:admin"], "default")).toBe(false);
39
+ });
40
+
41
+ test("no migrate scope → false", () => {
42
+ expect(hasMigrateScopeForVault([SCOPE_WRITE, SCOPE_READ], "default")).toBe(false);
43
+ expect(hasMigrateScopeForVault([], "default")).toBe(false);
44
+ });
45
+ });
46
+
23
47
  describe("parseScopes", () => {
24
48
  test("returns [] for null or empty input", () => {
25
49
  expect(parseScopes(null)).toEqual([]);
package/src/scopes.ts CHANGED
@@ -28,6 +28,19 @@ export const SCOPE_READ = "vault:read" as const;
28
28
  export const SCOPE_WRITE = "vault:write" as const;
29
29
  export const SCOPE_ADMIN = "vault:admin" as const;
30
30
 
31
+ /**
32
+ * Migration-bypass scope (vault#299). A SEPARATE capability axis — NOT part
33
+ * of the read ⊇ write ⊇ admin inheritance chain. A token holding
34
+ * `vault:migrate` (broad) or `vault:<name>:migrate` (narrowed) may skip
35
+ * `strict:true` schema enforcement so existing non-conforming notes can be
36
+ * migrated/backfilled. It is deliberately orthogonal: an `admin` token does
37
+ * NOT auto-bypass strict validation — bypass must be an explicit, audited
38
+ * grant. A bypass write still needs `write` to actually mutate (migrate is an
39
+ * ADD-ON flag, not a write grant on its own). Every bypassed write is logged
40
+ * (see the write path) since #300 (the audit-log table) is deferred.
41
+ */
42
+ export const SCOPE_MIGRATE = "vault:migrate" as const;
43
+
31
44
  /** All first-class vault scopes in inheritance order (lowest → highest). */
32
45
  export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
33
46
  export type VaultScope = (typeof VAULT_SCOPES)[number];
@@ -137,6 +150,56 @@ export function hasScopeForVault(
137
150
  return false;
138
151
  }
139
152
 
153
+ /**
154
+ * Migration-bypass check (vault#299): does `granted` hold the `migrate`
155
+ * capability for `vaultName`? Accepts broad `vault:migrate` (any vault) or
156
+ * narrowed `vault:<name>:migrate` (this vault only). Orthogonal to the
157
+ * read/write/admin verbs — a plain admin token returns `false` here, by
158
+ * design. The migrate scope does NOT live in `decomposeVaultScope` (it isn't a
159
+ * read/write/admin verb), so it's matched by exact shape here.
160
+ */
161
+ export function hasMigrateScopeForVault(granted: string[], vaultName: string): boolean {
162
+ for (const s of granted) {
163
+ if (s === SCOPE_MIGRATE) return true; // broad — any vault
164
+ if (s === `vault:${vaultName}:migrate`) return true; // narrowed — this vault
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Structured log line for a migration-bypassed write (vault#299 settled lead
171
+ * #2). Records WHO bypassed (actor/via from MW1 attribution), WHAT note, and
172
+ * WHICH strict violations were waived — enough to reconstruct the bypass for
173
+ * later audit without the #300 audit-log table (deferred). One JSON line so
174
+ * it's grep/jq-able in the daemon log. Lives here (next to the migrate-scope
175
+ * check) so both write transports — REST (routes.ts) and MCP (mcp-tools.ts) —
176
+ * emit the identical line without importing each other.
177
+ */
178
+ export function logStrictBypass(info: {
179
+ actor: string | null;
180
+ via: string | null;
181
+ path?: string | null;
182
+ tags?: string[];
183
+ violations: { field: string; reason: string; schema: string }[];
184
+ }): void {
185
+ console.warn(
186
+ "[schema-bypass] " +
187
+ JSON.stringify({
188
+ event: "strict_schema_bypass",
189
+ actor: info.actor,
190
+ via: info.via,
191
+ path: info.path ?? null,
192
+ tags: info.tags ?? [],
193
+ violations: info.violations.map((v) => ({
194
+ field: v.field,
195
+ reason: v.reason,
196
+ schema: v.schema,
197
+ })),
198
+ at: new Date().toISOString(),
199
+ }),
200
+ );
201
+ }
202
+
140
203
  /**
141
204
  * Pick the required scope for a given API request.
142
205
  * - GET/HEAD/OPTIONS → read
@@ -30,6 +30,7 @@ import {
30
30
  type TriggerInput,
31
31
  } from "../core/src/triggers-store.ts";
32
32
  import { registerVaultTrigger } from "./triggers.ts";
33
+ import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
33
34
  import { hasScopeForVault, SCOPE_ADMIN } from "./scopes.ts";
34
35
  import type { AuthResult } from "./auth.ts";
35
36
 
@@ -135,6 +136,29 @@ function validateInput(body: unknown):
135
136
  if (typeof b.when !== "object" || b.when === null || Array.isArray(b.when)) {
136
137
  return { ok: false, message: "`when` is required and must be an object" };
137
138
  }
139
+ // Value-matched metadata predicate (vault#299 Part B): when present, each
140
+ // field must map to an operator-object whose keys are all supported
141
+ // operators. Fail at registration so a typo'd operator (`{ state: { equals:
142
+ // ... } }`) is a 400, not a silently-never-firing trigger.
143
+ const whenMeta = (b.when as Record<string, unknown>).metadata;
144
+ if (whenMeta !== undefined) {
145
+ if (typeof whenMeta !== "object" || whenMeta === null || Array.isArray(whenMeta)) {
146
+ return { ok: false, message: "`when.metadata` must be an object mapping field → operator-object" };
147
+ }
148
+ for (const [field, opObj] of Object.entries(whenMeta as Record<string, unknown>)) {
149
+ if (typeof opObj !== "object" || opObj === null || Array.isArray(opObj)) {
150
+ return { ok: false, message: `\`when.metadata.${field}\` must be an operator-object, e.g. { eq: "published" }` };
151
+ }
152
+ for (const op of Object.keys(opObj as Record<string, unknown>)) {
153
+ if (!SUPPORTED_OPS.includes(op as (typeof SUPPORTED_OPS)[number])) {
154
+ return {
155
+ ok: false,
156
+ message: `\`when.metadata.${field}\` has unknown operator "${op}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
157
+ };
158
+ }
159
+ }
160
+ }
161
+ }
138
162
 
139
163
  if (typeof b.action !== "object" || b.action === null || Array.isArray(b.action)) {
140
164
  return { ok: false, message: "`action` is required and must be an object" };