@openparachute/vault 0.6.4-rc.1 → 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.
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 {
@@ -44,6 +46,52 @@ import { findTokensReferencingTag } from "./token-store.ts";
44
46
  export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
45
47
 
46
48
  const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
49
+
50
+ /**
51
+ * Write-attribution context (vault#298) for REST writes — the principal
52
+ * (`actor`) and interface (`via`) threaded from the authenticated request into
53
+ * `store.createNote` / `store.updateNote`. Both null for paths without an auth
54
+ * context (the no-op default). See `WriteContext` in core/src/notes.ts.
55
+ */
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
+ };
66
+
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
+
47
95
  import {
48
96
  expandContent,
49
97
  DEFAULT_EXPAND_DEPTH,
@@ -567,6 +615,13 @@ export function parseNotesQueryOpts(url: URL): {
567
615
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
568
616
  extension: parseExtensionFilter(url),
569
617
  metadata: bracket.metadata ?? metadataAlias.metadata,
618
+ // Write-attribution filters (vault#298) — symmetric with the MCP
619
+ // query-notes tool so a REST caller can ask "what did Mathilda write" /
620
+ // "what came in via the meeting-ingest surface" the same way.
621
+ createdBy: parseQuery(url, "created_by") ?? undefined,
622
+ lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
623
+ createdVia: parseQuery(url, "created_via") ?? undefined,
624
+ lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
570
625
  ...(bracket.dateFilter
571
626
  ? { dateFilter: bracket.dateFilter }
572
627
  : parseQuery(url, "date_field")
@@ -704,9 +759,10 @@ export async function handleNotes(
704
759
  subpath: string,
705
760
  vault?: string,
706
761
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
762
+ writeCtx: WriteCtx = NO_WRITE_CTX,
707
763
  ): Promise<Response> {
708
764
  try {
709
- return await handleNotesInner(req, store, subpath, vault, tagScope);
765
+ return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
710
766
  } catch (e: any) {
711
767
  const ambig = ambiguousPathResponse(e);
712
768
  if (ambig) return ambig;
@@ -720,6 +776,7 @@ async function handleNotesInner(
720
776
  subpath: string,
721
777
  vault?: string,
722
778
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
779
+ writeCtx: WriteCtx = NO_WRITE_CTX,
723
780
  ): Promise<Response> {
724
781
  const url = new URL(req.url);
725
782
  const method = req.method;
@@ -1102,6 +1159,13 @@ async function handleNotesInner(
1102
1159
  const extension = item.extension !== undefined
1103
1160
  ? validateExtension(item.extension)
1104
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
+ });
1105
1169
  const note = await store.createNote(item.content ?? "", {
1106
1170
  id: item.id,
1107
1171
  path: item.path,
@@ -1109,6 +1173,9 @@ async function handleNotesInner(
1109
1173
  metadata: item.metadata,
1110
1174
  created_at: item.createdAt ?? item.created_at,
1111
1175
  ...(extension !== undefined ? { extension } : {}),
1176
+ // Write-attribution (vault#298) — REST batch create.
1177
+ actor: writeCtx.actor,
1178
+ via: writeCtx.via,
1112
1179
  });
1113
1180
 
1114
1181
  // Create explicit links
@@ -1131,6 +1198,13 @@ async function handleNotesInner(
1131
1198
  409,
1132
1199
  );
1133
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
+ }
1134
1208
  if (e && e.code === "INVALID_EXTENSION") {
1135
1209
  return json(
1136
1210
  { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
@@ -1383,8 +1457,17 @@ async function handleNotesInner(
1383
1457
  ...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
1384
1458
  ...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
1385
1459
  ...(createExt !== undefined ? { extension: createExt } : {}),
1460
+ // Write-attribution (vault#298) — REST upsert-create branch.
1461
+ actor: writeCtx.actor,
1462
+ via: writeCtx.via,
1386
1463
  };
1387
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
+ });
1388
1471
  const created = await store.createNote(content, createOpts);
1389
1472
  if (tagsArr.length > 0) {
1390
1473
  await applySchemaDefaults(store, db, [created.id], tagsArr);
@@ -1474,7 +1557,19 @@ async function handleNotesInner(
1474
1557
  && body.createdAt === undefined
1475
1558
  && body.tags === undefined
1476
1559
  && body.links === undefined;
1477
- 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) {
1478
1573
  return json(
1479
1574
  {
1480
1575
  error_type: "precondition_required",
@@ -1566,8 +1661,39 @@ async function handleNotesInner(
1566
1661
  if (body.if_updated_at !== undefined) {
1567
1662
  updates.if_updated_at = body.if_updated_at;
1568
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
+ }
1569
1691
 
1570
1692
  if (Object.keys(updates).length > 0) {
1693
+ // Write-attribution (vault#298) — REST update. Stamp the most-recent-
1694
+ // write columns on the same UPDATE that bumps updated_at.
1695
+ updates.actor = writeCtx.actor;
1696
+ updates.via = writeCtx.via;
1571
1697
  await store.updateNote(note.id, updates);
1572
1698
  }
1573
1699
 
@@ -1661,6 +1787,39 @@ async function handleNotesInner(
1661
1787
  409,
1662
1788
  );
1663
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
+ }
1664
1823
  // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
1665
1824
  if (e && e.code === "PATH_CONFLICT") {
1666
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 {
@@ -71,6 +71,7 @@ import {
71
71
  handleStorage,
72
72
  handleViewNote,
73
73
  type TagScopeCtx,
74
+ type WriteCtx,
74
75
  } from "./routes.ts";
75
76
  import { handleSubscribe } from "./subscribe.ts";
76
77
  import { handleTriggers } from "./triggers-api.ts";
@@ -812,7 +813,22 @@ export async function route(
812
813
  raw: auth.scoped_tags,
813
814
  };
814
815
 
815
- if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
816
+ // Write-attribution context (vault#298). `auth.actor` is the principal;
817
+ // `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
818
+ // `operator` for the env-var bearer). The REST surface IS the `api` channel,
819
+ // so no refinement is needed here — the base via stands (the MCP handler is
820
+ // the one that refines to `mcp`). Threaded only into the write handler.
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
+ };
830
+
831
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
816
832
  // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
817
833
  // upsert/remove events over text/event-stream. Auth + tag-scope already
818
834
  // resolved above and threaded through, mirroring the /notes branch.
@@ -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" };
@@ -95,6 +95,39 @@ describe("buildPredicate", () => {
95
95
  expect(pred(makeNote({ tags: ["reader"] }))).toBe(false);
96
96
  expect(pred(makeNote({ tags: ["reader", "important"] }))).toBe(true);
97
97
  });
98
+
99
+ // Value-matched metadata grammar (vault#299 Part B).
100
+ it("metadata eq: fires only when the field equals the value", () => {
101
+ const pred = buildPredicate({ metadata: { state: { eq: "published" } } }, "publish_hook");
102
+ expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(true);
103
+ expect(pred(makeNote({ metadata: { state: "drafted" } }))).toBe(false);
104
+ expect(pred(makeNote({ metadata: {} }))).toBe(false); // absent → no fire
105
+ });
106
+
107
+ it("metadata in: fires on membership", () => {
108
+ const pred = buildPredicate({ metadata: { state: { in: ["produced", "published"] } } }, "h");
109
+ expect(pred(makeNote({ metadata: { state: "produced" } }))).toBe(true);
110
+ expect(pred(makeNote({ metadata: { state: "idea" } }))).toBe(false);
111
+ });
112
+
113
+ it("metadata combines with tag + presence filters (all must hold)", () => {
114
+ const pred = buildPredicate(
115
+ { tags: ["piece"], metadata: { state: { eq: "published" } }, missing_metadata: ["notified_at"] },
116
+ "h",
117
+ );
118
+ expect(pred(makeNote({ tags: ["piece"], metadata: { state: "published" } }))).toBe(true);
119
+ // right state, wrong tag.
120
+ expect(pred(makeNote({ tags: ["other"], metadata: { state: "published" } }))).toBe(false);
121
+ // right tag + state but already notified.
122
+ expect(
123
+ pred(makeNote({ tags: ["piece"], metadata: { state: "published", notified_at: "x" } })),
124
+ ).toBe(false);
125
+ });
126
+
127
+ it("a malformed operator never throws from the predicate (treated as no-match)", () => {
128
+ const pred = buildPredicate({ metadata: { state: { bogus: "x" } as any } }, "h");
129
+ expect(pred(makeNote({ metadata: { state: "published" } }))).toBe(false);
130
+ });
98
131
  });
99
132
 
100
133
  describe("registerTriggers — dispatch modes", async () => {
package/src/triggers.ts CHANGED
@@ -30,6 +30,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
30
30
  import crypto from "node:crypto";
31
31
  import type { Note, Store, Attachment } from "../core/src/types.ts";
32
32
  import type { HookRegistry, HookEvent, NoteHookPayload } from "../core/src/hooks.ts";
33
+ import { matchesOperator } from "../core/src/query-operators.ts";
33
34
  import type { TriggerConfig, TriggerWhen } from "./config.ts";
34
35
  import type { StoredTrigger } from "../core/src/triggers-store.ts";
35
36
  import { getVaultNameForStore } from "./vault-store.ts";
@@ -106,6 +107,22 @@ export function buildPredicate(when: TriggerWhen, triggerName: string): (note: N
106
107
  }
107
108
  }
108
109
 
110
+ // Value-matched metadata filter (vault#299 Part B). Each field's
111
+ // operator-object is evaluated against the note's live metadata with the
112
+ // SAME engine query-notes uses. ALL fields must match (AND). A malformed
113
+ // operator throws at registration-time validation, not here — by the time
114
+ // the predicate runs the shape is known-good, but we guard defensively so
115
+ // a bad config can never crash the hook dispatch loop (treat as no-match).
116
+ if (when.metadata) {
117
+ for (const [field, opObj] of Object.entries(when.metadata)) {
118
+ try {
119
+ if (!matchesOperator(field, meta?.[field], opObj)) return false;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ }
125
+
109
126
  return true;
110
127
  };
111
128
  }