@openparachute/vault 0.6.3 → 0.6.4-rc.10

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.
Files changed (51) hide show
  1. package/README.md +46 -11
  2. package/core/src/attribution.test.ts +273 -0
  3. package/core/src/core.test.ts +126 -0
  4. package/core/src/cursor.ts +8 -0
  5. package/core/src/enforced-writes.test.ts +533 -0
  6. package/core/src/mcp.ts +235 -9
  7. package/core/src/migrate-tag-field.test.ts +471 -0
  8. package/core/src/migrate-tag-field.ts +638 -0
  9. package/core/src/notes.ts +280 -7
  10. package/core/src/query-operators.ts +117 -0
  11. package/core/src/schema-defaults.ts +162 -9
  12. package/core/src/schema.ts +61 -2
  13. package/core/src/store.ts +51 -19
  14. package/core/src/tag-schemas.ts +12 -0
  15. package/core/src/triggers-store.ts +6 -0
  16. package/core/src/types.ts +35 -14
  17. package/core/src/vault-projection.ts +19 -0
  18. package/package.json +1 -1
  19. package/src/admin-spa.test.ts +18 -5
  20. package/src/admin-spa.ts +24 -3
  21. package/src/attribution-threading.test.ts +350 -0
  22. package/src/auth.ts +82 -4
  23. package/src/cli.ts +345 -9
  24. package/src/config.ts +11 -0
  25. package/src/first-boot-create.test.ts +155 -0
  26. package/src/import-daemon-busy.test.ts +8 -17
  27. package/src/mcp-http.ts +27 -0
  28. package/src/mcp-tools.ts +31 -4
  29. package/src/mirror-credentials.test.ts +47 -0
  30. package/src/mirror-credentials.ts +33 -0
  31. package/src/mirror-history.test.ts +426 -0
  32. package/src/mirror-manager.ts +202 -22
  33. package/src/mirror-routes.test.ts +78 -0
  34. package/src/mirror-routes.ts +195 -1
  35. package/src/module-config.ts +46 -80
  36. package/src/routes.ts +209 -41
  37. package/src/routing.test.ts +115 -25
  38. package/src/routing.ts +64 -2
  39. package/src/scale.bench.test.ts +82 -0
  40. package/src/scopes.test.ts +24 -0
  41. package/src/scopes.ts +63 -0
  42. package/src/self-register.test.ts +5 -5
  43. package/src/self-register.ts +8 -3
  44. package/src/server.ts +58 -38
  45. package/src/subscribe.test.ts +23 -2
  46. package/src/test-support/spawn.ts +85 -0
  47. package/src/triggers-api.ts +24 -0
  48. package/src/triggers.test.ts +33 -0
  49. package/src/triggers.ts +17 -0
  50. package/src/vault-remove.test.ts +4 -5
  51. package/src/vault.test.ts +188 -17
@@ -14,31 +14,26 @@
14
14
  *
15
15
  * PUT /.parachute/config is Phase 3 — not implemented here.
16
16
  *
17
+ * Scope boundary (vault#478): `GET /.parachute/config` is gated by
18
+ * `vault:<name>:admin` — admin over *your* vault only — so it describes and
19
+ * returns ONLY per-vault config, never daemon-GLOBAL settings.
20
+ *
17
21
  * Fields currently described:
18
22
  * - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
19
- * - port: GlobalConfig.port, exposed read-only.
20
- * - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
21
- * Part 2). Three nested fields per design Q4:
22
- * - enabled: boolean toggle, default true when scribe is
23
- * reachable (persisted in
24
- * GlobalConfig.auto_transcribe.enabled). Default
25
- * flipped from off → on so installing scribe is
26
- * the only opt-in signal needed.
27
- * - scribeUrl: readOnly resolved per-process from
28
- * `~/.parachute/services.json` via
29
- * `scribe-discovery.ts`. Operators can't point at an
30
- * arbitrary scribe; the discovery layer is the gate.
31
- * - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
32
- * Hub install generates one at first boot
33
- * (see scribe-env.ts:ensureScribeBearer); manual
34
- * rotation is via `parachute-vault config set`.
35
- * - scribe_url / scribe_token (deprecated): kept under their legacy names
36
- * through one release for the hub admin SPA's prior
37
- * render path; new code should read autoTranscribe.*.
23
+ * - autoTranscribe.enabled: per-vault toggle (vault#353). Reports the value
24
+ * THIS vault will use, resolving per-vault override
25
+ * (VaultConfig.auto_transcribe.enabled) server
26
+ * default (GlobalConfig.auto_transcribe.enabled)
27
+ * true. The inherited fallback is the per-vault
28
+ * truth, not a leak of the global setting.
29
+ *
30
+ * Deliberately NOT exposed here (daemon-global operator-only surface):
31
+ * - port: GlobalConfig.port (deployment-wide listen port).
32
+ * - autoTranscribe.scribeUrl / scribe_url: discovery-resolved per-process.
33
+ * - autoTranscribe.scribeBearer / scribe_token: shared SCRIBE_AUTH_TOKEN.
38
34
  */
39
35
 
40
36
  import type { VaultConfig, GlobalConfig } from "./config.ts";
41
- import { resolveScribeUrl } from "./scribe-discovery.ts";
42
37
 
43
38
  export interface ModuleConfigSchema {
44
39
  $schema: string;
@@ -75,83 +70,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
75
70
  default: true,
76
71
  title: "Enable auto-transcription",
77
72
  description:
78
- "Master toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable. Global persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
79
- },
80
- scribeUrl: {
81
- type: "string",
82
- format: "uri",
83
- readOnly: true,
84
- title: "Scribe URL",
85
- description:
86
- "URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
87
- },
88
- scribeBearer: {
89
- type: "string",
90
- writeOnly: true,
91
- title: "Scribe auth bearer",
92
- description:
93
- "Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
73
+ "Per-vault toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable for THIS vault. Resolves per-vault override server default on, so leaving it unset inherits the deployment default.",
94
74
  },
95
75
  },
96
76
  },
97
- // Legacy aliases kept for back-compat with callers that read the
98
- // pre-vault#353 shape. New consumers should read `autoTranscribe.*`.
99
- scribe_url: {
100
- type: "string",
101
- format: "uri",
102
- title: "Scribe URL (deprecated alias)",
103
- description:
104
- "Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
105
- readOnly: true,
106
- deprecated: true,
107
- },
108
- scribe_token: {
109
- type: "string",
110
- title: "Scribe auth token (deprecated alias)",
111
- description:
112
- "Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
113
- writeOnly: true,
114
- deprecated: true,
115
- },
116
- port: {
117
- type: "integer",
118
- minimum: 1,
119
- maximum: 65535,
120
- title: "HTTP port",
121
- description: "Port the vault server listens on. Set at init time; changing requires a restart.",
122
- readOnly: true,
123
- },
77
+ // NOTE (vault#478): daemon-GLOBAL fields (the listen `port`, the
78
+ // discovery-resolved scribe URL, the shared scribe bearer) are
79
+ // deliberately NOT described here. This endpoint is gated by
80
+ // `vault:<name>:admin` — admin over *your* vault only — so it neither
81
+ // describes nor returns deployment-wide settings. Those live behind the
82
+ // operator-only surface (CLI / global config).
124
83
  },
125
84
  };
126
85
  }
127
86
 
128
87
  /**
129
- * Effective config values, with `writeOnly` fields stripped. `scribeBearer`
130
- * (and its legacy alias `scribe_token`) are declared `writeOnly` and never
131
- * returned, even when set in the environment.
88
+ * Effective config values for ONE vault. The shared scribe bearer is
89
+ * daemon-global and never returned (see the scope boundary below).
90
+ *
91
+ * Scope boundary (vault#478): the `GET /vault/<name>/.parachute/config`
92
+ * endpoint is gated by `vault:<name>:admin` — "admin over *your* vault only".
93
+ * It must therefore return ONLY per-vault config, never daemon-GLOBAL settings
94
+ * (the listen `port`, the discovery-resolved scribe URL, the server-wide
95
+ * auto-transcribe default). Those describe the whole deployment, not this
96
+ * vault, and leaking them across the per-vault admin boundary matters once a
97
+ * shared multi-vault daemon hands admin-on-one-vault to a beta signup. They
98
+ * live behind the operator-only surface (the CLI / global config), not here.
99
+ *
100
+ * `autoTranscribe.enabled` IS per-vault: it reports the value THIS vault will
101
+ * actually use, resolving per-vault override → global default → true (mirrors
102
+ * `shouldAutoTranscribe`). That's a per-vault effective value, not the raw
103
+ * daemon-global toggle — reporting it doesn't leak the global setting (an
104
+ * unset vault simply inherits, which is the per-vault truth).
132
105
  */
133
106
  export function buildConfigValues(
134
107
  vaultConfig: VaultConfig,
135
108
  globalConfig: GlobalConfig,
136
- env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
137
109
  ): Record<string, unknown> {
138
- // Resolve scribe URL through the discovery layer so the GET shape reflects
139
- // what the worker will actually use (services.json > SCRIBE_URL > unset).
140
- // Pass env through so the test harness's override is honored.
141
- const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
142
110
  return {
143
111
  audio_retention: vaultConfig.audio_retention ?? "keep",
144
112
  autoTranscribe: {
145
- // Match shouldAutoTranscribe's `?? true` so the admin SPA displays
146
- // the same value runtime uses. An unset config row shows `true`
147
- // because that's what vault will actually do on the next audio upload.
148
- enabled: globalConfig.auto_transcribe?.enabled ?? true,
149
- scribeUrl,
113
+ // Per-vault effective value: this vault's own override wins; otherwise it
114
+ // inherits the server default; otherwise true. Same ladder as
115
+ // shouldAutoTranscribe, so the admin SPA shows what this vault will do.
116
+ enabled:
117
+ vaultConfig.auto_transcribe?.enabled
118
+ ?? globalConfig.auto_transcribe?.enabled
119
+ ?? true,
150
120
  },
151
- // Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
152
- // pre-vault#353 shape don't regress.
153
- scribe_url: scribeUrl,
154
- port: globalConfig.port,
155
121
  };
156
122
  }
157
123
 
package/src/routes.ts CHANGED
@@ -14,16 +14,19 @@
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
18
  import {
19
19
  parseContentRange,
20
20
  applyContentRange,
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";
29
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
27
30
  import {
28
31
  buildExpandVisibility,
29
32
  filterHydratedLinksByTagScope,
@@ -44,6 +47,52 @@ import { findTokensReferencingTag } from "./token-store.ts";
44
47
  export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
45
48
 
46
49
  const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
50
+
51
+ /**
52
+ * Write-attribution context (vault#298) for REST writes — the principal
53
+ * (`actor`) and interface (`via`) threaded from the authenticated request into
54
+ * `store.createNote` / `store.updateNote`. Both null for paths without an auth
55
+ * context (the no-op default). See `WriteContext` in core/src/notes.ts.
56
+ */
57
+ export type WriteCtx = {
58
+ actor: string | null;
59
+ via: string | null;
60
+ /**
61
+ * Migration-bypass (vault#299). True when the caller holds `vault:migrate`
62
+ * — strict-schema enforcement is skipped and the bypass is logged. Defaults
63
+ * to false (full enforcement) for every normal write.
64
+ */
65
+ bypassStrict?: boolean;
66
+ };
67
+
68
+ const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
69
+
70
+ /**
71
+ * Run the shared strict-schema gate for a REST write (vault#299). Mirrors the
72
+ * MCP path's `enforceStrict` closure: enforce unless the caller holds the
73
+ * migration-bypass scope, and log every bypassed write to the daemon's
74
+ * structured log (the audit-log table, #300, is deferred). Throws
75
+ * `SchemaValidationError` (caught by the route's catch → 422) when not bypassed.
76
+ */
77
+ function gateStrictWrite(
78
+ store: Store,
79
+ writeCtx: WriteCtx,
80
+ shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
81
+ ): void {
82
+ enforceStrictWrite(store, shape, {
83
+ bypass: writeCtx.bypassStrict === true,
84
+ onBypass: (violations: ValidationWarning[]) => {
85
+ logStrictBypass({
86
+ actor: writeCtx.actor,
87
+ via: writeCtx.via,
88
+ path: shape.path ?? null,
89
+ tags: shape.tags,
90
+ violations,
91
+ });
92
+ },
93
+ });
94
+ }
95
+
47
96
  import {
48
97
  expandContent,
49
98
  DEFAULT_EXPAND_DEPTH,
@@ -279,7 +328,7 @@ function parseMetaBrackets(url: URL): {
279
328
  return {
280
329
  error: json(
281
330
  {
282
- error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract uses these two ops because the equivalent flat shape (\`date_field=${field}&date_from=…&date_to=…\`) is half-open by design.`,
331
+ error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract is half-open by design.`,
283
332
  code: "INVALID_QUERY",
284
333
  },
285
334
  400,
@@ -567,20 +616,20 @@ export function parseNotesQueryOpts(url: URL): {
567
616
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
568
617
  extension: parseExtensionFilter(url),
569
618
  metadata: bracket.metadata ?? metadataAlias.metadata,
570
- ...(bracket.dateFilter
571
- ? { dateFilter: bracket.dateFilter }
572
- : parseQuery(url, "date_field")
573
- ? {
574
- dateFilter: {
575
- field: parseQuery(url, "date_field")!,
576
- from: parseQuery(url, "date_from") ?? undefined,
577
- to: parseQuery(url, "date_to") ?? undefined,
578
- },
579
- }
580
- : {
581
- dateFrom: parseQuery(url, "date_from") ?? undefined,
582
- dateTo: parseQuery(url, "date_to") ?? undefined,
583
- }),
619
+ // Write-attribution filters (vault#298) — symmetric with the MCP
620
+ // query-notes tool so a REST caller can ask "what did Mathilda write" /
621
+ // "what came in via the meeting-ingest surface" the same way.
622
+ createdBy: parseQuery(url, "created_by") ?? undefined,
623
+ lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
624
+ createdVia: parseQuery(url, "created_via") ?? undefined,
625
+ lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
626
+ // Date-range filtering on the query string is bracket-style only:
627
+ // `?meta[created_at][gte]=…&meta[created_at][lt]=…` (see
628
+ // `parseMetaBrackets`). The flat `date_field` / `date_from` / `date_to`
629
+ // params were removed in 0.6.4 (vault#288, breaking) — they are now
630
+ // ignored. The MCP `date_from` / `date_to` shorthand is a separate,
631
+ // supported convenience and is unaffected.
632
+ ...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
584
633
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
585
634
  orderBy: parseQuery(url, "order_by") ?? undefined,
586
635
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -704,9 +753,10 @@ export async function handleNotes(
704
753
  subpath: string,
705
754
  vault?: string,
706
755
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
756
+ writeCtx: WriteCtx = NO_WRITE_CTX,
707
757
  ): Promise<Response> {
708
758
  try {
709
- return await handleNotesInner(req, store, subpath, vault, tagScope);
759
+ return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
710
760
  } catch (e: any) {
711
761
  const ambig = ambiguousPathResponse(e);
712
762
  if (ambig) return ambig;
@@ -720,6 +770,7 @@ async function handleNotesInner(
720
770
  subpath: string,
721
771
  vault?: string,
722
772
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
773
+ writeCtx: WriteCtx = NO_WRITE_CTX,
723
774
  ): Promise<Response> {
724
775
  const url = new URL(req.url);
725
776
  const method = req.method;
@@ -849,7 +900,7 @@ async function handleNotesInner(
849
900
 
850
901
  // Structured query
851
902
  //
852
- // Two filter syntaxes coexist on this endpoint:
903
+ // Date-range filtering on the query string uses one syntax:
853
904
  //
854
905
  // - **Bracket-style** (canonical, vault#285 friction point 1.3):
855
906
  // `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
@@ -857,21 +908,18 @@ async function handleNotesInner(
857
908
  // not_in/exists) and the dateFilter bridge through one consistent
858
909
  // shape. See `parseMetaBrackets` for the grammar.
859
910
  //
860
- // - **Flat date params** (DEPRECATED): `?date_field=created_at&
861
- // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
862
- // Still functional through 0.5.x; planned removal in a later 0.x
863
- // (vault#288). New consumers should use bracket-style.
864
- //
865
- // Precedence on overlap: bracket-style wins. If a caller passes both
866
- // `meta[created_at][gte]=X` and `date_field=created_at&date_from=Y`,
867
- // the bracket form is the dateFilter the engine sees; the flat
868
- // params are silently dropped. We don't error — the bracket form is
869
- // documented as canonical, and rejecting the overlap would block a
870
- // realistic migration path where a caller half-converted their code.
911
+ // The flat date params (`?date_field=created_at&date_from=…&date_to=…`
912
+ // and the legacy bare `?date_from=…&date_to=…`) were REMOVED in 0.6.4
913
+ // (vault#288, breaking change). They are now silently ignored — a
914
+ // request that passes only flat date params comes back unfiltered.
915
+ // Bracket-style is functionally complete (full operator set), so no
916
+ // capability was lost. Migrate `date_field=created_at&date_from=X` to
917
+ // `meta[created_at][gte]=X` (and `date_to=Y` to `meta[created_at][lt]=Y`).
871
918
  //
872
919
  // Surface asymmetry: REST flattens to a query string; MCP takes a
873
- // nested `date_filter: { field, from, to }` object directly. Both
874
- // lower to the same store-level `dateFilter` shape.
920
+ // nested `date_filter: { field, from, to }` object directly (plus a
921
+ // top-level `date_from` / `date_to` shorthand, which is unaffected by
922
+ // this removal). Both lower to the same store-level `dateFilter` shape.
875
923
  // Structured-query parsing is shared with the live `/subscribe` route
876
924
  // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
877
925
  // string to the same `QueryOpts` — predicate parity by construction.
@@ -1102,6 +1150,13 @@ async function handleNotesInner(
1102
1150
  const extension = item.extension !== undefined
1103
1151
  ? validateExtension(item.extension)
1104
1152
  : undefined;
1153
+ // Strict-schema gate (vault#299) — reject before any write so a
1154
+ // mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
1155
+ gateStrictWrite(store, writeCtx, {
1156
+ path: item.path,
1157
+ tags: item.tags,
1158
+ metadata: item.metadata,
1159
+ });
1105
1160
  const note = await store.createNote(item.content ?? "", {
1106
1161
  id: item.id,
1107
1162
  path: item.path,
@@ -1109,6 +1164,9 @@ async function handleNotesInner(
1109
1164
  metadata: item.metadata,
1110
1165
  created_at: item.createdAt ?? item.created_at,
1111
1166
  ...(extension !== undefined ? { extension } : {}),
1167
+ // Write-attribution (vault#298) — REST batch create.
1168
+ actor: writeCtx.actor,
1169
+ via: writeCtx.via,
1112
1170
  });
1113
1171
 
1114
1172
  // Create explicit links
@@ -1131,6 +1189,13 @@ async function handleNotesInner(
1131
1189
  409,
1132
1190
  );
1133
1191
  }
1192
+ // Strict-schema rejection (vault#299 Part A) on create — 422.
1193
+ if (e && e.code === "SCHEMA_VALIDATION") {
1194
+ return json(
1195
+ { error_type: "schema_validation", error: "schema_validation", violations: e.violations ?? [], message: e.message },
1196
+ 422,
1197
+ );
1198
+ }
1134
1199
  if (e && e.code === "INVALID_EXTENSION") {
1135
1200
  return json(
1136
1201
  { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
@@ -1383,8 +1448,17 @@ async function handleNotesInner(
1383
1448
  ...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
1384
1449
  ...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
1385
1450
  ...(createExt !== undefined ? { extension: createExt } : {}),
1451
+ // Write-attribution (vault#298) — REST upsert-create branch.
1452
+ actor: writeCtx.actor,
1453
+ via: writeCtx.via,
1386
1454
  };
1387
1455
  const content = (body.content as string | undefined) ?? "";
1456
+ // Strict-schema gate (vault#299) — if_missing:"create" is a create.
1457
+ gateStrictWrite(store, writeCtx, {
1458
+ path: createOpts.path ?? undefined,
1459
+ tags: createOpts.tags,
1460
+ metadata: createOpts.metadata,
1461
+ });
1388
1462
  const created = await store.createNote(content, createOpts);
1389
1463
  if (tagsArr.length > 0) {
1390
1464
  await applySchemaDefaults(store, db, [created.id], tagsArr);
@@ -1474,7 +1548,19 @@ async function handleNotesInner(
1474
1548
  && body.createdAt === undefined
1475
1549
  && body.tags === undefined
1476
1550
  && body.links === undefined;
1477
- if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
1551
+ // A state_transition is itself a compare-and-set precondition (vault#299
1552
+ // Part B) — a transition-only PATCH needs no if_updated_at/force.
1553
+ const isTransitionOnly = body.state_transition !== undefined
1554
+ && !hasContent
1555
+ && !hasAppendPrepend
1556
+ && !hasContentEdit
1557
+ && body.path === undefined
1558
+ && body.metadata === undefined
1559
+ && body.created_at === undefined
1560
+ && body.createdAt === undefined
1561
+ && body.tags === undefined
1562
+ && body.links === undefined;
1563
+ if (!isAppendOnly && !isTransitionOnly && body.if_updated_at === undefined && body.force !== true) {
1478
1564
  return json(
1479
1565
  {
1480
1566
  error_type: "precondition_required",
@@ -1557,8 +1643,11 @@ async function handleNotesInner(
1557
1643
  updates.extension = validateExtension(body.extension);
1558
1644
  }
1559
1645
  if (body.metadata !== undefined) {
1560
- const existing = (note.metadata as Record<string, unknown>) ?? {};
1561
- updates.metadata = { ...existing, ...body.metadata };
1646
+ // RFC 7386 merge: incoming `null` removes the key (vault#478/#479).
1647
+ updates.metadata = mergeMetadata(
1648
+ note.metadata as Record<string, unknown> | null | undefined,
1649
+ body.metadata as Record<string, unknown>,
1650
+ );
1562
1651
  }
1563
1652
  if (body.created_at !== undefined || body.createdAt !== undefined) {
1564
1653
  updates.created_at = body.created_at ?? body.createdAt;
@@ -1566,8 +1655,39 @@ async function handleNotesInner(
1566
1655
  if (body.if_updated_at !== undefined) {
1567
1656
  updates.if_updated_at = body.if_updated_at;
1568
1657
  }
1658
+ // Compare-and-set state transition (vault#299 Part B). Combinable with
1659
+ // other field updates; folds into the same atomic UPDATE.
1660
+ const stBody = body.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1661
+ if (stBody !== undefined) {
1662
+ if (typeof stBody.field !== "string" || stBody.field.length === 0) {
1663
+ return json(
1664
+ { error: "bad_request", message: "`state_transition.field` must be a non-empty string." },
1665
+ 400,
1666
+ );
1667
+ }
1668
+ updates.state_transition = { field: stBody.field, from: stBody.from, to: stBody.to };
1669
+ }
1670
+
1671
+ // --- Strict-schema gate (vault#299 Part A) ---
1672
+ // Validate the PROSPECTIVE shape (final tags + merged metadata, incl. a
1673
+ // state_transition's `to`) before the write so a rejection leaves the
1674
+ // note untouched. Throws SchemaValidationError → 422 in the catch.
1675
+ {
1676
+ const removeSet = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
1677
+ const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
1678
+ for (const t of (body.tags?.add as string[] | undefined) ?? []) projectedTags.add(t);
1679
+ const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
1680
+ const projectedMeta = stBody !== undefined
1681
+ ? { ...baseMeta, [stBody.field as string]: stBody.to }
1682
+ : baseMeta;
1683
+ gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1684
+ }
1569
1685
 
1570
1686
  if (Object.keys(updates).length > 0) {
1687
+ // Write-attribution (vault#298) — REST update. Stamp the most-recent-
1688
+ // write columns on the same UPDATE that bumps updated_at.
1689
+ updates.actor = writeCtx.actor;
1690
+ updates.via = writeCtx.via;
1571
1691
  await store.updateNote(note.id, updates);
1572
1692
  }
1573
1693
 
@@ -1661,6 +1781,39 @@ async function handleNotesInner(
1661
1781
  409,
1662
1782
  );
1663
1783
  }
1784
+ // State-transition compare-and-set conflict (vault#299 Part B). A
1785
+ // DISTINCT error vocabulary from `conflict` (settled lead #3): the
1786
+ // field VALUE didn't match `from`, not the updated_at token. 409.
1787
+ if (e && e.code === "TRANSITION_CONFLICT") {
1788
+ return json(
1789
+ {
1790
+ error_type: "transition_conflict",
1791
+ error: "transition_conflict",
1792
+ note_id: e.note_id,
1793
+ path: e.note_path ?? null,
1794
+ field: e.field,
1795
+ expected_from: e.expected_from,
1796
+ to: e.to,
1797
+ current: e.current ?? null,
1798
+ message: e.message,
1799
+ },
1800
+ 409,
1801
+ );
1802
+ }
1803
+ // Strict-schema rejection (vault#299 Part A). One error carrying ALL
1804
+ // per-field violations (settled lead #1). 422 Unprocessable Entity —
1805
+ // the note exists / request is well-formed but violates the contract.
1806
+ if (e && e.code === "SCHEMA_VALIDATION") {
1807
+ return json(
1808
+ {
1809
+ error_type: "schema_validation",
1810
+ error: "schema_validation",
1811
+ violations: e.violations ?? [],
1812
+ message: e.message,
1813
+ },
1814
+ 422,
1815
+ );
1816
+ }
1664
1817
  // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
1665
1818
  if (e && e.code === "PATH_CONFLICT") {
1666
1819
  return json(
@@ -1934,12 +2087,27 @@ export async function handleTags(
1934
2087
  fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
1935
2088
  }
1936
2089
 
1937
- const result = await store.upsertTagRecord(tagName, {
1938
- ...(body.description !== undefined ? { description: body.description } : {}),
1939
- ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
1940
- ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
1941
- ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
1942
- });
2090
+ // A bad indexed-field name (or an unindexable type, or a cross-tag type
2091
+ // mismatch) is a CLIENT error return 400, not the catch-all 500. The
2092
+ // store pre-validates and is transactional, so the schema is left
2093
+ // unchanged on failure (no orphan/lying index). vault#478.
2094
+ let result;
2095
+ try {
2096
+ result = await store.upsertTagRecord(tagName, {
2097
+ ...(body.description !== undefined ? { description: body.description } : {}),
2098
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
2099
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
2100
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
2101
+ });
2102
+ } catch (err) {
2103
+ if (err instanceof IndexedFieldError) {
2104
+ return json(
2105
+ { error: err.message, error_type: "invalid_indexed_field" },
2106
+ 400,
2107
+ );
2108
+ }
2109
+ throw err;
2110
+ }
1943
2111
  return json(result);
1944
2112
  }
1945
2113