@openparachute/vault 0.4.0 → 0.4.4-rc.11

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.
@@ -1,73 +1,105 @@
1
1
  /**
2
- * Schema validation: resolves which schemas apply to a note (by path prefix
3
- * or tag), then validates the note's metadata against each. Writes are
4
- * never blocked schemas are guidance. The MCP/REST layer surfaces a
5
- * `validation_status` block on create/update responses with any warnings
6
- * the agent can act on (missing required, type mismatch, enum mismatch).
2
+ * Schema validation: walk the tags carried by a note (plus their ancestors via
3
+ * `parent_names`), look up each ancestor's `fields` declaration, merge them
4
+ * (first-in-walk wins on conflict), and validate the note's metadata against
5
+ * the merged field map. Writes are never blocked — schemas are guidance. The
6
+ * MCP/REST layer surfaces a `validation_status` block on create/update
7
+ * responses with any warnings the agent can act on (type mismatch, enum
8
+ * mismatch, schema conflict).
7
9
  *
8
- * Storage (post-v15): schemas live in the `note_schemas` table; mapping
9
- * rules live in `schema_mappings`. Authoring is via `update-note-schema`
10
- * + `set-schema-mapping` (MCP/REST). The legacy `_schemas/<name>` and
11
- * `_schema_defaults` notes are retired but left in place — inert after
12
- * v15 (no resolver reads them).
10
+ * Storage: schemas live as `fields` columns on the `tags` table — same row
11
+ * that already carries description, relationships, and parent_names. Authoring
12
+ * is via `update-tag` (MCP) or PATCH /api/tags/:name (REST).
13
+ *
14
+ * This was a two-table subsystem (`note_schemas` + `schema_mappings`) prior
15
+ * to v17 — see vault#267. Removed in v17 because zero operator vaults used
16
+ * the path-prefix mapping kind, and tag-mapped schemas were fully redundant
17
+ * with `tags.fields`. The single-axis tag-driven validation lives here.
18
+ *
19
+ * Inheritance (vault#270, 2026-05-09):
20
+ * - A note's effective ancestor set = union of {tag ∪ ancestors(tag)} for each
21
+ * tag on the note, walking `parent_names` recursively (cycle-safe).
22
+ * - `_default` is an implicit universal parent: if a tag named `_default`
23
+ * exists in the tags table, it's appended to every note's effective ancestor
24
+ * set (including untagged notes). The `tags.parent_names` column is never
25
+ * auto-mutated — the magic lives at resolve time only.
26
+ * - Conflict resolution: first-in-walk wins. The walk visits each note tag
27
+ * in order, then DFS through its `parent_names` array in declaration order,
28
+ * so "first-in-`parent_names`-array wins" is the operator-controlled
29
+ * precedence. Conflicts surface as advisory `schema_conflict` warnings; no
30
+ * write blocking.
31
+ * - `_default` can technically carry its own `parent_names` and the resolver
32
+ * handles it (cycle guard + visited Set), but the resulting interaction is
33
+ * non-obvious — `_default` is usually appended last, so its ancestors
34
+ * become low-precedence. Treat `_default` as a root tag in normal use.
13
35
  *
14
36
  * Resolution model:
15
37
  * - Lazy: rebuilt on first access, cached on the store.
16
- * - Invalidated when `note_schemas` or `schema_mappings` are mutated
17
- * (table writes, not note writes).
18
- * - When no mappings exist and nothing else matches, validation is a
19
- * no-op (status omitted).
38
+ * - Invalidated when `tags.fields` or `tags.parent_names` is mutated, when a
39
+ * tag is deleted, renamed, or merged.
40
+ * - When no ancestor declares fields, validation is a no-op (status omitted).
20
41
  */
21
42
 
22
43
  import { Database } from "bun:sqlite";
23
44
 
24
- // ---------------------------------------------------------------------------
25
- // Legacy path prefixes — kept exported for any historical caller that still
26
- // references them. No resolver code reads notes-as-config post-v15.
27
- // ---------------------------------------------------------------------------
28
-
29
- export const SCHEMA_CONFIG_PREFIX = "_schemas/";
30
- export const SCHEMA_DEFAULTS_PATH = "_schema_defaults";
31
-
32
45
  // ---------------------------------------------------------------------------
33
46
  // Types
34
47
  // ---------------------------------------------------------------------------
35
48
 
36
49
  export interface SchemaField {
37
- type?: "string" | "number" | "boolean" | "array" | "object";
50
+ /**
51
+ * Declared type for the field's metadata value. `"integer"` is distinct
52
+ * from `"number"` only at validation time — JSON has no separate integer
53
+ * type, so a JSON number with zero fractional part (`5`, `5.0`,
54
+ * `Number.isInteger(n) === true`) is accepted as integer and a non-zero
55
+ * fractional value (`5.5`) is rejected. This matches the indexed-fields
56
+ * `"integer"` storage type (TYPE_MAP) and removes the false-positive
57
+ * `type_mismatch` warning that previously fired on every integer-shaped
58
+ * field because the validator had no `"integer"` case. See vault#310.
59
+ */
60
+ type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
38
61
  enum?: string[];
39
62
  description?: string;
40
63
  }
41
64
 
42
- export interface SchemaDefinition {
43
- name: string;
44
- description?: string;
45
- fields: Record<string, SchemaField>;
46
- required: string[];
47
- }
48
-
49
- export interface SchemaDefaults {
50
- /** Path prefix → schema name. Longest prefix wins on tie. */
51
- pathPrefixes: Array<{ prefix: string; schema: string }>;
52
- /** Tag → schema name. */
53
- tagToSchema: Map<string, string>;
54
- }
55
-
65
+ /**
66
+ * Tag-record snapshot used by the resolver. Loaded from the `tags` table once
67
+ * and cached on the store. `allTags` carries every known name (so `_default`
68
+ * existence checks and query expansion can be answered without a re-read).
69
+ */
56
70
  export interface ResolvedSchemas {
57
- defaults: SchemaDefaults;
58
- definitions: Map<string, SchemaDefinition>;
71
+ /** Set of all known tag names (for `_default` magic + presence checks). */
72
+ allTags: Set<string>;
73
+ /** Per-tag own fields (only entries with at least one declaration). */
74
+ tagToFields: Map<string, Record<string, SchemaField>>;
75
+ /** Per-tag `parent_names` (only entries with at least one parent declared). */
76
+ tagToParents: Map<string, string[]>;
59
77
  }
60
78
 
61
79
  export interface ValidationWarning {
62
80
  field: string;
81
+ /** Tag whose schema declared the violated field (or won the conflict). */
63
82
  schema: string;
64
- /** "missing_required" | "type_mismatch" | "enum_mismatch" */
65
- reason: "missing_required" | "type_mismatch" | "enum_mismatch";
83
+ /**
84
+ * `type_mismatch` value's type contradicts the declared `type`.
85
+ * `enum_mismatch` — string value not in the declared `enum`.
86
+ * `schema_conflict` — two ancestors declared the same field with
87
+ * different specs; first-in-walk wins, the loser surfaces here so the
88
+ * operator can resolve the disagreement.
89
+ */
90
+ reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
66
91
  message: string;
92
+ /**
93
+ * `schema_conflict` only — the tag whose declaration was overridden. Set
94
+ * when `reason === "schema_conflict"`; absent on type/enum mismatches.
95
+ * Surfaces structurally so agents don't have to regex `message` to find
96
+ * the loser.
97
+ */
98
+ loser_schema?: string;
67
99
  }
68
100
 
69
101
  export interface ValidationStatus {
70
- /** Schema names that matched the note (for transparency). */
102
+ /** Tag names whose schemas contributed at least one field to the merged map. */
71
103
  schemas: string[];
72
104
  /** Empty when all checks pass. */
73
105
  warnings: ValidationWarning[];
@@ -99,55 +131,34 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
99
131
  return fields;
100
132
  }
101
133
 
102
- function parseRequiredJson(raw: string | null): string[] {
134
+ function parseParentsJson(raw: string | null): string[] {
103
135
  if (!raw) return [];
104
- try {
105
- const parsed = JSON.parse(raw);
106
- if (!Array.isArray(parsed)) return [];
107
- return parsed.filter((x): x is string => typeof x === "string");
108
- } catch {
109
- return [];
110
- }
136
+ let parsed: unknown;
137
+ try { parsed = JSON.parse(raw); } catch { return []; }
138
+ if (!Array.isArray(parsed)) return [];
139
+ return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
111
140
  }
112
141
 
113
142
  /**
114
- * Build the full resolution map from the `note_schemas` and `schema_mappings`
115
- * tables. Always returns a well-formed `ResolvedSchemas` even when the
116
- * tables are empty (empty maps).
143
+ * Build a resolution map from the `tags` table. Returns a well-formed
144
+ * `ResolvedSchemas` even when no tag declares fields (empty `tagToFields`).
117
145
  */
118
146
  export function loadSchemaConfig(db: Database): ResolvedSchemas {
119
- const definitions = new Map<string, SchemaDefinition>();
120
- const defRows = db.prepare(
121
- `SELECT name, description, fields, required FROM note_schemas`,
122
- ).all() as { name: string; description: string | null; fields: string | null; required: string | null }[];
123
- for (const row of defRows) {
147
+ const allTags = new Set<string>();
148
+ const tagToFields = new Map<string, Record<string, SchemaField>>();
149
+ const tagToParents = new Map<string, string[]>();
150
+ const rows = db.prepare(
151
+ `SELECT name, fields, parent_names FROM tags`,
152
+ ).all() as { name: string; fields: string | null; parent_names: string | null }[];
153
+ for (const row of rows) {
124
154
  if (!row.name) continue;
125
- definitions.set(row.name, {
126
- name: row.name,
127
- description: row.description ?? undefined,
128
- fields: parseFieldsJson(row.fields),
129
- required: parseRequiredJson(row.required),
130
- });
131
- }
132
-
133
- const defaults: SchemaDefaults = {
134
- pathPrefixes: [],
135
- tagToSchema: new Map(),
136
- };
137
- const mappingRows = db.prepare(
138
- `SELECT schema_name, match_kind, match_value FROM schema_mappings`,
139
- ).all() as { schema_name: string; match_kind: string; match_value: string }[];
140
- for (const row of mappingRows) {
141
- if (row.match_kind === "path_prefix") {
142
- defaults.pathPrefixes.push({ prefix: row.match_value, schema: row.schema_name });
143
- } else if (row.match_kind === "tag") {
144
- defaults.tagToSchema.set(row.match_value, row.schema_name);
145
- }
155
+ allTags.add(row.name);
156
+ const fields = parseFieldsJson(row.fields);
157
+ if (Object.keys(fields).length > 0) tagToFields.set(row.name, fields);
158
+ const parents = parseParentsJson(row.parent_names);
159
+ if (parents.length > 0) tagToParents.set(row.name, parents);
146
160
  }
147
- // Longest prefix wins sort once at load so resolve is O(n) without re-sorts.
148
- defaults.pathPrefixes.sort((a, b) => b.prefix.length - a.prefix.length);
149
-
150
- return { defaults, definitions };
161
+ return { allTags, tagToFields, tagToParents };
151
162
  }
152
163
 
153
164
  // ---------------------------------------------------------------------------
@@ -155,41 +166,111 @@ export function loadSchemaConfig(db: Database): ResolvedSchemas {
155
166
  // ---------------------------------------------------------------------------
156
167
 
157
168
  /**
158
- * Find the schemas that apply to a note based on its path and tags. Returns
159
- * schema *names* in the order they were resolved (path-prefix first, then
160
- * each matching tag in declaration order). Names that don't have a row in
161
- * `note_schemas` are dropped.
169
+ * Walk-order accumulator for a single note's effective ancestor set. DFS
170
+ * through `parent_names` in declaration order, cycle-protected via a visited
171
+ * Set. The output array preserves first-encounter order so the field-merge
172
+ * pass can apply first-wins precedence.
173
+ *
174
+ * Exported so other consumers (vault projection, future hierarchy
175
+ * inspectors) can reuse the exact walk semantics rather than carrying
176
+ * their own copy. Mutating walks (push to `out`, add to `visited`) keep
177
+ * the implementation cheap; callers pass fresh accumulators.
162
178
  */
163
- export function resolveApplicableSchemas(
179
+ export function walkAncestors(
180
+ startTag: string,
164
181
  resolved: ResolvedSchemas,
165
- note: { path?: string | null; tags?: string[] },
166
- ): string[] {
167
- const names: string[] = [];
168
- const seen = new Set<string>();
182
+ visited: Set<string>,
183
+ out: string[],
184
+ ): void {
185
+ if (visited.has(startTag)) return;
186
+ visited.add(startTag);
187
+ out.push(startTag);
188
+ const parents = resolved.tagToParents.get(startTag);
189
+ if (!parents) return;
190
+ for (const p of parents) {
191
+ walkAncestors(p, resolved, visited, out);
192
+ }
193
+ }
169
194
 
170
- if (note.path) {
171
- for (const { prefix, schema } of resolved.defaults.pathPrefixes) {
172
- if (note.path.startsWith(prefix)) {
173
- if (!seen.has(schema) && resolved.definitions.has(schema)) {
174
- names.push(schema);
175
- seen.add(schema);
176
- }
177
- break; // longest match wins (sorted at load)
178
- }
179
- }
195
+ interface MergedField {
196
+ spec: SchemaField;
197
+ sourceTag: string;
198
+ }
199
+
200
+ interface NoteResolution {
201
+ /** Walk-order tag list whose `fields` contributed at least one entry. */
202
+ effectiveTags: string[];
203
+ /** Field name → winning spec + source tag. First-in-walk wins. */
204
+ mergedFields: Map<string, MergedField>;
205
+ /** Conflict warnings — same field declared by ≥2 ancestors with diverging specs. */
206
+ conflicts: ValidationWarning[];
207
+ }
208
+
209
+ /**
210
+ * Resolve the effective schema for a note. Walks each note tag through its
211
+ * `parent_names` chain (cycle-safe), implicitly appends `_default` when the
212
+ * tag exists, and merges all encountered `fields` declarations with
213
+ * first-in-walk precedence. A note with no tags still picks up `_default`'s
214
+ * schema when one is declared.
215
+ *
216
+ * Internal — exported for tests. The public entry point is `validateNote`.
217
+ */
218
+ export function resolveNoteSchemas(
219
+ resolved: ResolvedSchemas,
220
+ note: { tags?: string[] },
221
+ ): NoteResolution {
222
+ const visited = new Set<string>();
223
+ const order: string[] = [];
224
+
225
+ for (const tag of note.tags ?? []) {
226
+ walkAncestors(tag, resolved, visited, order);
227
+ }
228
+ if (resolved.allTags.has("_default")) {
229
+ walkAncestors("_default", resolved, visited, order);
180
230
  }
181
231
 
182
- if (note.tags) {
183
- for (const tag of note.tags) {
184
- const schema = resolved.defaults.tagToSchema.get(tag);
185
- if (schema && !seen.has(schema) && resolved.definitions.has(schema)) {
186
- names.push(schema);
187
- seen.add(schema);
232
+ const mergedFields = new Map<string, MergedField>();
233
+ const conflicts: ValidationWarning[] = [];
234
+
235
+ for (const tagName of order) {
236
+ const fields = resolved.tagToFields.get(tagName);
237
+ if (!fields) continue;
238
+ for (const [fieldName, spec] of Object.entries(fields)) {
239
+ const existing = mergedFields.get(fieldName);
240
+ if (!existing) {
241
+ mergedFields.set(fieldName, { spec, sourceTag: tagName });
242
+ continue;
188
243
  }
244
+ if (fieldSpecsEqual(existing.spec, spec)) continue;
245
+ conflicts.push({
246
+ field: fieldName,
247
+ schema: existing.sourceTag,
248
+ loser_schema: tagName,
249
+ reason: "schema_conflict",
250
+ message: `field '${fieldName}' has conflicting specs in ancestor tags '${existing.sourceTag}' (kept) and '${tagName}' (ignored)`,
251
+ });
189
252
  }
190
253
  }
191
254
 
192
- return names;
255
+ const contributing = new Set<string>();
256
+ for (const { sourceTag } of mergedFields.values()) contributing.add(sourceTag);
257
+ const effectiveTags = order.filter((t) => contributing.has(t));
258
+
259
+ return { effectiveTags, mergedFields, conflicts };
260
+ }
261
+
262
+ function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
263
+ if (a.type !== b.type) return false;
264
+ if (!stringArraysEqual(a.enum, b.enum)) return false;
265
+ return true;
266
+ }
267
+
268
+ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): boolean {
269
+ if (a === b) return true;
270
+ if (!a || !b) return false;
271
+ if (a.length !== b.length) return false;
272
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
273
+ return true;
193
274
  }
194
275
 
195
276
  function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
@@ -199,6 +280,14 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
199
280
  return typeof value === "string";
200
281
  case "number":
201
282
  return typeof value === "number" && Number.isFinite(value);
283
+ case "integer":
284
+ // JSON has no separate integer type — `5.0` and `5` decode to the
285
+ // same JS Number. Accept any finite Number whose fractional part is
286
+ // zero; reject `5.5`, `NaN`, `Infinity`, and non-Number types.
287
+ // vault#310 (Gitcoin Brain drift detector emits JSON for diffs;
288
+ // without this case, every integer-typed field warned
289
+ // `type_mismatch` and buried the real warnings).
290
+ return typeof value === "number" && Number.isInteger(value);
202
291
  case "boolean":
203
292
  return typeof value === "boolean";
204
293
  case "array":
@@ -209,79 +298,52 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
209
298
  }
210
299
 
211
300
  /**
212
- * Validate a note's metadata against each applicable schema and collect
213
- * warnings. Validation is non-blocking the caller decides what to do with
214
- * the warnings (currently: surface them on the create/update response).
301
+ * Validate a note's metadata against the merged schema. Returns null when no
302
+ * ancestor declares any fields (so the caller can omit `validation_status`
303
+ * entirely). Otherwise returns the status with conflict warnings prepended,
304
+ * followed by per-field type/enum mismatches.
215
305
  *
216
- * Rules per field:
217
- * - In `required` and absent → `missing_required`
306
+ * Rules per merged field:
218
307
  * - Present and `type` declared and value's type doesn't match → `type_mismatch`
219
308
  * - Present and `enum` declared and value not in enum → `enum_mismatch`
220
309
  *
221
- * Fields not declared in the schema are ignored entirely (this isn't a
222
- * "strict" validator — it's a guide).
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.
223
313
  */
224
- export function validateMetadata(
314
+ export function validateNote(
225
315
  resolved: ResolvedSchemas,
226
- schemaNames: string[],
227
- metadata: Record<string, unknown> | undefined,
228
- ): ValidationStatus {
229
- const warnings: ValidationWarning[] = [];
230
- const m = metadata ?? {};
231
-
232
- for (const name of schemaNames) {
233
- const def = resolved.definitions.get(name);
234
- if (!def) continue;
316
+ note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
317
+ ): ValidationStatus | null {
318
+ const resolution = resolveNoteSchemas(resolved, note);
319
+ if (resolution.mergedFields.size === 0) return null;
235
320
 
236
- for (const requiredField of def.required) {
237
- if (!(requiredField in m) || m[requiredField] === undefined || m[requiredField] === null) {
238
- warnings.push({
239
- field: requiredField,
240
- schema: name,
241
- reason: "missing_required",
242
- message: `'${requiredField}' is required by schema '${name}'`,
243
- });
244
- }
245
- }
321
+ const m = note.metadata ?? {};
322
+ const warnings: ValidationWarning[] = [...resolution.conflicts];
246
323
 
247
- for (const [fieldName, field] of Object.entries(def.fields)) {
248
- if (!(fieldName in m)) continue;
249
- const value = m[fieldName];
250
- if (value === undefined || value === null) continue;
324
+ 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;
251
328
 
252
- if (field.type && !valueMatchesType(value, field.type)) {
253
- warnings.push({
254
- field: fieldName,
255
- schema: name,
256
- reason: "type_mismatch",
257
- message: `'${fieldName}' should be ${field.type} (schema '${name}')`,
258
- });
259
- }
329
+ if (spec.type && !valueMatchesType(value, spec.type)) {
330
+ warnings.push({
331
+ field: fieldName,
332
+ schema: sourceTag,
333
+ reason: "type_mismatch",
334
+ message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
335
+ });
336
+ }
260
337
 
261
- if (field.enum && field.enum.length > 0 && typeof value === "string" && !field.enum.includes(value)) {
262
- warnings.push({
263
- field: fieldName,
264
- schema: name,
265
- reason: "enum_mismatch",
266
- message: `'${fieldName}' must be one of [${field.enum.join(", ")}] (schema '${name}')`,
267
- });
268
- }
338
+ if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
339
+ warnings.push({
340
+ field: fieldName,
341
+ schema: sourceTag,
342
+ reason: "enum_mismatch",
343
+ message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
344
+ });
269
345
  }
270
346
  }
271
347
 
272
- return { schemas: schemaNames, warnings };
273
- }
274
-
275
- /**
276
- * Convenience: combine resolve + validate for a note. Returns null when no
277
- * schemas apply (so the caller can decide whether to omit the field on the
278
- * response or surface an empty status).
279
- */
280
- export function validateNote(
281
- resolved: ResolvedSchemas,
282
- note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
283
- ): ValidationStatus | null {
284
- const names = resolveApplicableSchemas(resolved, note);
285
- if (names.length === 0) return null;
286
- return validateMetadata(resolved, names, note.metadata);
348
+ return { schemas: resolution.effectiveTags, warnings };
287
349
  }