@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
@@ -60,6 +60,33 @@ export interface SchemaField {
60
60
  type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
61
61
  enum?: string[];
62
62
  description?: string;
63
+ /**
64
+ * Strict enforcement opt-in (vault#299, Part A). Default `false` — when
65
+ * unset/false, ALL constraints on this field are advisory: violations
66
+ * surface as `validation_status` warnings and the write succeeds (the
67
+ * historical byte-identical behavior). When `true`, ALL declared
68
+ * constraints on this field (type + enum + required + cardinality) flip
69
+ * to hard write rejections, all-or-nothing per field (the Gitcoin team's
70
+ * call — "enum + required together, not enum alone"). Free-form fields
71
+ * (`notes`/`description`) on an otherwise-strict tag stay advisory by
72
+ * simply leaving `strict` off — strictness is per-field, not per-tag.
73
+ */
74
+ strict?: boolean;
75
+ /**
76
+ * Whether the field MUST be present (and non-null) on a note carrying this
77
+ * tag. Advisory under `strict:false` (a `required` violation surfaces as a
78
+ * `missing_required` warning); a hard rejection only when `strict:true`.
79
+ */
80
+ required?: boolean;
81
+ /**
82
+ * Cardinality constraint for the field's value. `"one"` (the implicit
83
+ * default) means a single scalar — an array value is a `cardinality`
84
+ * violation. `"many"` means the value must be an array. Advisory under
85
+ * `strict:false`; a hard rejection when `strict:true`. Distinct from the
86
+ * relationship cardinality vocabulary (that governs typed links, not
87
+ * metadata fields).
88
+ */
89
+ cardinality?: "one" | "many";
63
90
  }
64
91
 
65
92
  /**
@@ -83,19 +110,35 @@ export interface ValidationWarning {
83
110
  /**
84
111
  * `type_mismatch` — value's type contradicts the declared `type`.
85
112
  * `enum_mismatch` — string value not in the declared `enum`.
113
+ * `missing_required` — a `required` field is absent or null (vault#299).
114
+ * `cardinality_mismatch` — value's shape (scalar vs array) contradicts the
115
+ * declared `cardinality` (vault#299).
86
116
  * `schema_conflict` — two ancestors declared the same field with
87
117
  * different specs; first-in-walk wins, the loser surfaces here so the
88
118
  * operator can resolve the disagreement.
89
119
  */
90
- reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
120
+ reason:
121
+ | "type_mismatch"
122
+ | "enum_mismatch"
123
+ | "missing_required"
124
+ | "cardinality_mismatch"
125
+ | "schema_conflict";
91
126
  message: string;
92
127
  /**
93
128
  * `schema_conflict` only — the tag whose declaration was overridden. Set
94
- * when `reason === "schema_conflict"`; absent on type/enum mismatches.
129
+ * when `reason === "schema_conflict"`; absent on other reasons.
95
130
  * Surfaces structurally so agents don't have to regex `message` to find
96
131
  * the loser.
97
132
  */
98
133
  loser_schema?: string;
134
+ /**
135
+ * `true` when this violation comes from a `strict:true` field (vault#299).
136
+ * A strict violation is an ENFORCEMENT error — the write path rejects it.
137
+ * Absent/false for advisory warnings (the historical guidance behavior).
138
+ * Surfaced structurally so the write path can split "block" from "warn"
139
+ * without re-deriving the field's strict flag.
140
+ */
141
+ strict?: boolean;
99
142
  }
100
143
 
101
144
  export interface ValidationStatus {
@@ -126,6 +169,9 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
126
169
  if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
127
170
  if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
128
171
  if (typeof f.description === "string") field.description = f.description;
172
+ if (f.strict === true) field.strict = true;
173
+ if (f.required === true) field.required = true;
174
+ if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
129
175
  fields[k] = field;
130
176
  }
131
177
  return fields;
@@ -301,15 +347,23 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
301
347
  * Validate a note's metadata against the merged schema. Returns null when no
302
348
  * ancestor declares any fields (so the caller can omit `validation_status`
303
349
  * entirely). Otherwise returns the status with conflict warnings prepended,
304
- * followed by per-field type/enum mismatches.
350
+ * followed by per-field violations.
305
351
  *
306
352
  * Rules per merged field:
353
+ * - `required` declared and value absent/null → `missing_required`
307
354
  * - Present and `type` declared and value's type doesn't match → `type_mismatch`
308
355
  * - Present and `enum` declared and value not in enum → `enum_mismatch`
356
+ * - Present and `cardinality` declared and shape (scalar vs array) wrong
357
+ * → `cardinality_mismatch`
358
+ *
359
+ * Every violation carries `strict: true` iff its field declared `strict:true`
360
+ * (vault#299). The list itself is the SAME whether or not a field is strict —
361
+ * the difference is only the per-warning `strict` flag, which the write path
362
+ * uses to decide block-vs-warn. Under `strict:false` this is byte-identical to
363
+ * the historical advisory behavior PLUS the new `required`/`cardinality`
364
+ * advisory reasons (which fire for any field declaring those, strict or not).
309
365
  *
310
- * Fields not declared by any ancestor's schema are ignored entirely (this
311
- * isn't a "strict" validator — it's a guide). There is no `required` concept
312
- * (post-v17); declarations are advisory only.
366
+ * Fields not declared by any ancestor's schema are ignored entirely.
313
367
  */
314
368
  export function validateNote(
315
369
  resolved: ResolvedSchemas,
@@ -322,9 +376,25 @@ export function validateNote(
322
376
  const warnings: ValidationWarning[] = [...resolution.conflicts];
323
377
 
324
378
  for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
325
- if (!(fieldName in m)) continue;
326
- const value = m[fieldName];
327
- if (value === undefined || value === null) continue;
379
+ const strictFlag = spec.strict === true ? { strict: true } : {};
380
+ const present = fieldName in m;
381
+ const value = present ? m[fieldName] : undefined;
382
+ const absent = !present || value === undefined || value === null;
383
+
384
+ if (spec.required === true && absent) {
385
+ warnings.push({
386
+ field: fieldName,
387
+ schema: sourceTag,
388
+ reason: "missing_required",
389
+ message: `'${fieldName}' is required (tag '${sourceTag}')`,
390
+ ...strictFlag,
391
+ });
392
+ // A required field that's absent has no value to type/enum/cardinality
393
+ // check — the missing_required violation stands alone.
394
+ continue;
395
+ }
396
+
397
+ if (absent) continue;
328
398
 
329
399
  if (spec.type && !valueMatchesType(value, spec.type)) {
330
400
  warnings.push({
@@ -332,6 +402,7 @@ export function validateNote(
332
402
  schema: sourceTag,
333
403
  reason: "type_mismatch",
334
404
  message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
405
+ ...strictFlag,
335
406
  });
336
407
  }
337
408
 
@@ -341,9 +412,91 @@ export function validateNote(
341
412
  schema: sourceTag,
342
413
  reason: "enum_mismatch",
343
414
  message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
415
+ ...strictFlag,
344
416
  });
345
417
  }
418
+
419
+ if (spec.cardinality) {
420
+ const isArray = Array.isArray(value);
421
+ const wantMany = spec.cardinality === "many";
422
+ if (wantMany !== isArray) {
423
+ warnings.push({
424
+ field: fieldName,
425
+ schema: sourceTag,
426
+ reason: "cardinality_mismatch",
427
+ message: wantMany
428
+ ? `'${fieldName}' must be an array (cardinality 'many', tag '${sourceTag}')`
429
+ : `'${fieldName}' must be a single value, not an array (cardinality 'one', tag '${sourceTag}')`,
430
+ ...strictFlag,
431
+ });
432
+ }
433
+ }
346
434
  }
347
435
 
348
436
  return { schemas: resolution.effectiveTags, warnings };
349
437
  }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Strict enforcement (vault#299)
441
+ // ---------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Thrown by the write path when a note violates one or more `strict:true`
445
+ * field constraints. Carries ALL per-field violations in a single error (the
446
+ * settled design lead — one `SchemaValidationError`, not per-axis errors), so
447
+ * an agent sees the whole contract it broke in one response and can fix every
448
+ * field before retrying.
449
+ *
450
+ * Each entry is a `ValidationWarning` with `strict: true`. `code` is stable
451
+ * (`SCHEMA_VALIDATION`) for transport mapping (MCP error / HTTP 422).
452
+ */
453
+ export class SchemaValidationError extends Error {
454
+ code = "SCHEMA_VALIDATION" as const;
455
+ violations: ValidationWarning[];
456
+
457
+ constructor(violations: ValidationWarning[]) {
458
+ const summary = violations
459
+ .map((v) => `${v.field}: ${v.reason}`)
460
+ .join("; ");
461
+ super(`schema_validation: ${violations.length} strict field violation(s) — ${summary}`);
462
+ this.name = "SchemaValidationError";
463
+ this.violations = violations;
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Extract the enforcement-level (strict) subset of a validation status —
469
+ * the violations the write path must reject. Conflict warnings are advisory
470
+ * by nature (they describe operator schema disagreements, not note data) and
471
+ * are never enforced even when a field is strict. Returns `[]` when nothing
472
+ * strict was violated (the common case — the caller then proceeds with the
473
+ * write).
474
+ */
475
+ export function strictViolations(status: ValidationStatus | null): ValidationWarning[] {
476
+ if (!status) return [];
477
+ return status.warnings.filter((w) => w.strict === true && w.reason !== "schema_conflict");
478
+ }
479
+
480
+ /**
481
+ * Validate-and-enforce: run `validateNote`, and if any `strict:true` field is
482
+ * violated, throw a single `SchemaValidationError` carrying every violation.
483
+ * Returns the full advisory `ValidationStatus` (or null) on success so the
484
+ * caller can still surface advisory warnings on the response. `bypass: true`
485
+ * skips the throw entirely (migration-bypass scope) — the caller is
486
+ * responsible for logging the bypass.
487
+ *
488
+ * The write path calls this BEFORE persisting so a rejection leaves the note
489
+ * untouched.
490
+ */
491
+ export function enforceStrictSchema(
492
+ resolved: ResolvedSchemas,
493
+ note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
494
+ opts?: { bypass?: boolean },
495
+ ): { status: ValidationStatus | null; violations: ValidationWarning[] } {
496
+ const status = validateNote(resolved, note);
497
+ const violations = strictViolations(status);
498
+ if (violations.length > 0 && opts?.bypass !== true) {
499
+ throw new SchemaValidationError(violations);
500
+ }
501
+ return { status, violations };
502
+ }
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 22;
5
+ export const SCHEMA_VERSION = 23;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record.
@@ -13,6 +13,17 @@ export const SCHEMA_SQL = `
13
13
  -- column; on-disk uniqueness key is (path, extension). See
14
14
  -- core/src/portable-md.ts:supportsInlineFrontmatter for the
15
15
  -- frontmatter-vs-sidecar split.
16
+ --
17
+ -- Write-attribution (v23, vault#298) — two axes of provenance, both nullable:
18
+ -- created_by / created_via — the principal + interface of the FIRST
19
+ -- write (set once at create; never rewritten).
20
+ -- last_updated_by / last_updated_via — the principal + interface of the MOST
21
+ -- RECENT write (set on every mutating update).
22
+ -- The *_by columns are the actor (a JWT sub, or an operator/token label for
23
+ -- non-JWT auth); the *_via columns are the channel the write arrived through
24
+ -- (mcp, surface:NAME, agent:ID, operator/cli, api). Legacy rows stay NULL —
25
+ -- we don't fabricate authors for writes that predate attribution. See
26
+ -- migrateToV23.
16
27
  CREATE TABLE IF NOT EXISTS notes (
17
28
  id TEXT PRIMARY KEY,
18
29
  content TEXT DEFAULT '',
@@ -20,7 +31,11 @@ CREATE TABLE IF NOT EXISTS notes (
20
31
  metadata TEXT DEFAULT '{}',
21
32
  created_at TEXT NOT NULL,
22
33
  updated_at TEXT,
23
- extension TEXT NOT NULL DEFAULT 'md'
34
+ extension TEXT NOT NULL DEFAULT 'md',
35
+ created_by TEXT,
36
+ created_via TEXT,
37
+ last_updated_by TEXT,
38
+ last_updated_via TEXT
24
39
  );
25
40
 
26
41
  -- Tags: first-class identity carrying schema, hierarchy, and typed-link
@@ -479,6 +494,13 @@ export function initSchema(db: Database): void {
479
494
  // scans. See the 2026-06-10 query-perf measurements.
480
495
  migrateToV22(db);
481
496
 
497
+ // Migrate v22 → v23: write-attribution columns on `notes`
498
+ // (created_by/created_via/last_updated_by/last_updated_via) + their indexes.
499
+ // All four nullable; existing rows backfill to NULL ("written before
500
+ // attribution" — we don't fabricate authors for legacy writes). See
501
+ // vault#298.
502
+ migrateToV23(db);
503
+
482
504
  // Rebuild any generated columns + indexes declared in indexed_fields.
483
505
  // No-op for a fresh vault; idempotent on existing vaults.
484
506
  rebuildIndexes(db);
@@ -1150,6 +1172,43 @@ function migrateToV22(db: Database): void {
1150
1172
  db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
1151
1173
  }
1152
1174
 
1175
+ /**
1176
+ * Migrate v22 → v23: per-identity + per-interface write attribution
1177
+ * (vault#298). Adds four nullable columns to `notes` and an index on each so
1178
+ * "what did Mathilda write" / "what came in via the meeting-ingest surface"
1179
+ * are indexed lookups, not scans:
1180
+ *
1181
+ * created_by — principal (actor) of the first write
1182
+ * created_via — interface/channel of the first write
1183
+ * last_updated_by — principal of the most recent write
1184
+ * last_updated_via — interface/channel of the most recent write
1185
+ *
1186
+ * `*_by` is the JWT `sub` (or an operator / `token:<id>` label for non-JWT
1187
+ * auth); `*_via` is the channel (`mcp`, `surface:<name>`, `agent:<id>`,
1188
+ * `operator`/`cli`, `api`). All four NULL on legacy rows — we deliberately do
1189
+ * NOT backfill an author for writes that predate attribution; NULL reads as
1190
+ * "unknown / pre-attribution," distinct from any real principal.
1191
+ *
1192
+ * Columns live here (not SCHEMA_SQL's index block) following the
1193
+ * idx_tokens_vault_name / idx_notes_updated precedent: SCHEMA_SQL runs before
1194
+ * the migration steps, so an upgrading v22 vault doesn't yet have the columns
1195
+ * when that block evaluates. Fresh vaults get the columns from the CREATE
1196
+ * TABLE above and the indexes from this same path. All idempotent — the
1197
+ * column-existence guard + CREATE INDEX IF NOT EXISTS make re-runs no-ops.
1198
+ */
1199
+ function migrateToV23(db: Database): void {
1200
+ if (!hasTable(db, "notes")) return;
1201
+ const cols = ["created_by", "created_via", "last_updated_by", "last_updated_via"];
1202
+ for (const col of cols) {
1203
+ if (!hasColumn(db, "notes", col)) {
1204
+ db.exec(`ALTER TABLE notes ADD COLUMN ${col} TEXT`);
1205
+ }
1206
+ }
1207
+ for (const col of cols) {
1208
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_${col} ON notes(${col})`);
1209
+ }
1210
+ }
1211
+
1153
1212
  function hasTable(db: Database, name: string): boolean {
1154
1213
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1155
1214
  return !!row;
package/core/src/store.ts CHANGED
@@ -100,7 +100,7 @@ export class BunSqliteStore implements Store {
100
100
 
101
101
  // ---- Notes ----
102
102
 
103
- async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
103
+ async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note> {
104
104
  const note = noteOps.createNote(this.db, content, opts);
105
105
 
106
106
  if (content) {
@@ -140,6 +140,9 @@ export class BunSqliteStore implements Store {
140
140
  metadata?: Record<string, unknown>;
141
141
  created_at?: string;
142
142
  skipUpdatedAt?: boolean;
143
+ // Write-attribution (vault#298) — principal + interface of this edit.
144
+ actor?: string | null;
145
+ via?: string | null;
143
146
  if_updated_at?: string;
144
147
  },
145
148
  ): Promise<Note> {
@@ -628,36 +631,65 @@ export class BunSqliteStore implements Store {
628
631
  const priorRecord =
629
632
  patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
630
633
 
631
- const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
632
-
634
+ const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
635
+ new Set(
636
+ Object.entries(fields ?? {})
637
+ .filter(([, v]) => v.indexed === true)
638
+ .map(([k]) => k),
639
+ );
640
+ const nextFields = patch.fields; // object | null | undefined
641
+ const priorIndexed = indexedSet(priorRecord?.fields);
642
+ const nextIndexed = indexedSet(nextFields);
643
+
644
+ // PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
645
+ // field name (or unmappable type) must fail closed — the schema record
646
+ // must NOT be written when the backing index can't be created. Pre-checking
647
+ // here, before the transaction even opens, turns the failure into a clean
648
+ // caller error (IndexedFieldError → 400) and leaves the schema untouched.
649
+ // Without this, the prior code persisted the field declaration, THEN threw
650
+ // on declareField — a 500 plus a tag claiming an index the engine can't
651
+ // build (the "lying schema" loop). See vault#478.
633
652
  if (patch.fields !== undefined) {
634
- const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
635
- new Set(
636
- Object.entries(fields ?? {})
637
- .filter(([, v]) => v.indexed === true)
638
- .map(([k]) => k),
639
- );
640
- const nextFields = patch.fields; // object | null
641
- const priorIndexed = indexedSet(priorRecord?.fields);
642
- const nextIndexed = indexedSet(nextFields);
643
653
  for (const fieldName of nextIndexed) {
644
654
  const spec = nextFields![fieldName]!;
645
655
  const mapped = indexedFieldOps.mapFieldType(spec.type);
646
- // Unmappable type for indexing is a caller error; surface it rather
647
- // than silently skipping. MCP/REST validate up-front for a cleaner
648
- // message, but this is the backstop at the chokepoint.
649
656
  if (!mapped) {
650
657
  throw new indexedFieldOps.IndexedFieldError(
651
658
  `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
652
659
  );
653
660
  }
654
- indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
661
+ // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
662
+ indexedFieldOps.validateFieldName(fieldName);
655
663
  }
656
- for (const fieldName of priorIndexed) {
657
- if (!nextIndexed.has(fieldName)) {
658
- indexedFieldOps.releaseField(this.db, fieldName, tag);
664
+ }
665
+
666
+ // Persist the record + reconcile the indexed-field lifecycle atomically.
667
+ // If declareField throws inside the transaction (e.g. a cross-tag type
668
+ // mismatch only detectable once the existing declarer set is consulted),
669
+ // the whole write rolls back — the schema never ends up claiming an index
670
+ // that doesn't exist. vault#478 transactional fix.
671
+ let result: tagSchemaOps.TagRecord;
672
+ this.db.exec("BEGIN IMMEDIATE");
673
+ try {
674
+ result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
675
+
676
+ if (patch.fields !== undefined) {
677
+ for (const fieldName of nextIndexed) {
678
+ const spec = nextFields![fieldName]!;
679
+ // Type already validated above; non-null assertion is safe here.
680
+ const mapped = indexedFieldOps.mapFieldType(spec.type)!;
681
+ indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
682
+ }
683
+ for (const fieldName of priorIndexed) {
684
+ if (!nextIndexed.has(fieldName)) {
685
+ indexedFieldOps.releaseField(this.db, fieldName, tag);
686
+ }
659
687
  }
660
688
  }
689
+ this.db.exec("COMMIT");
690
+ } catch (err) {
691
+ this.db.exec("ROLLBACK");
692
+ throw err;
661
693
  }
662
694
 
663
695
  if (patch.parent_names !== undefined) {
@@ -30,6 +30,18 @@ export interface TagFieldSchema {
30
30
  // across declarers — all tags declaring this field must agree on both
31
31
  // `type` and `indexed`. See core/src/indexed-fields.ts for lifecycle.
32
32
  indexed?: boolean;
33
+ // Strict-enforcement opt-in (vault#299). Default false (advisory). When
34
+ // true, ALL declared constraints on this field (type + enum + required +
35
+ // cardinality) flip from validation_status warnings to hard write
36
+ // rejections — all-or-nothing per field. Stored verbatim in the `fields`
37
+ // JSON column; the resolver (schema-defaults.ts) interprets it.
38
+ strict?: boolean;
39
+ // The field must be present + non-null on a note carrying this tag.
40
+ // Advisory unless `strict:true`. vault#299.
41
+ required?: boolean;
42
+ // "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
43
+ // Distinct from relationship cardinality. vault#299.
44
+ cardinality?: "one" | "many";
33
45
  }
34
46
 
35
47
  /**
@@ -27,6 +27,12 @@ export interface StoredTriggerWhen {
27
27
  has_content?: boolean;
28
28
  missing_metadata?: string[];
29
29
  has_metadata?: string[];
30
+ /**
31
+ * Value-matched metadata predicate (vault#299 Part B). field →
32
+ * operator-object, evaluated with the shared query-operators engine. See
33
+ * src/config.ts:TriggerWhen.metadata.
34
+ */
35
+ metadata?: Record<string, Record<string, unknown>>;
30
36
  }
31
37
 
32
38
  /** Webhook auth — only the bearer-JWT path for now. */
package/core/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
2
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
4
  import type { TagExpandMode } from "./tag-hierarchy.js";
5
+ import type { ValidationStatus } from "./schema-defaults.js";
5
6
 
6
7
  // ---- Re-exports ----
7
8
 
@@ -26,6 +27,19 @@ export interface Note {
26
27
  metadata?: Record<string, unknown>;
27
28
  createdAt: string; // ISO-8601
28
29
  updatedAt?: string;
30
+ /**
31
+ * Write-attribution (vault#298) — two axes of provenance, both nullable.
32
+ * `*By` is the principal (a JWT `sub`, or an operator / `token:<id>` label);
33
+ * `*Via` is the interface the write arrived through (`mcp`, `surface:<name>`,
34
+ * `agent:<id>`, `operator`/`cli`, `api`). The `created*` pair is set once at
35
+ * create; the `lastUpdated*` pair tracks the most recent mutating write. NULL
36
+ * = unknown / written before attribution existed (legacy rows) or by a path
37
+ * that carried no context — distinct from any real principal.
38
+ */
39
+ createdBy?: string | null;
40
+ createdVia?: string | null;
41
+ lastUpdatedBy?: string | null;
42
+ lastUpdatedVia?: string | null;
29
43
  tags?: string[];
30
44
  links?: Link[];
31
45
  /**
@@ -125,6 +139,15 @@ export interface QueryOpts {
125
139
  // for the field. Operator queries require the field to be declared
126
140
  // `indexed: true` in a tag schema; undeclared fields error loudly.
127
141
  metadata?: Record<string, unknown>;
142
+ // Write-attribution filters (vault#298). Exact-match on the indexed
143
+ // attribution columns — "what did Mathilda write" (`createdBy`/`lastUpdatedBy`)
144
+ // or "what came in via the meeting-ingest surface"
145
+ // (`createdVia`/`lastUpdatedVia`). Each is an exact string match; multiple
146
+ // AND together with the rest of the filter set.
147
+ createdBy?: string;
148
+ lastUpdatedBy?: string;
149
+ createdVia?: string;
150
+ lastUpdatedVia?: string;
128
151
  // Legacy shorthand: filters on `n.created_at` (vault ingestion time).
129
152
  // Equivalent to `dateFilter: { field: "created_at", from, to }`. Kept
130
153
  // as the common path; specifying both this and `dateFilter` rejects.
@@ -206,6 +229,12 @@ export interface NoteIndex {
206
229
  extension?: string;
207
230
  createdAt: string;
208
231
  updatedAt?: string;
232
+ /** Write-attribution (vault#298) — carried on the lean shape too so "who
233
+ * touched what" is answerable without re-fetching full content. See `Note`. */
234
+ createdBy?: string | null;
235
+ createdVia?: string | null;
236
+ lastUpdatedBy?: string | null;
237
+ lastUpdatedVia?: string | null;
209
238
  tags?: string[];
210
239
  metadata?: Record<string, unknown>;
211
240
  byteSize: number;
@@ -232,8 +261,10 @@ export interface Store {
232
261
  */
233
262
  readonly db: Database;
234
263
 
235
- // Notes
236
- createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
264
+ // Notes. `actor` / `via` carry write-attribution (vault#298) — the
265
+ // principal + interface stamped onto created_by/created_via (and mirrored
266
+ // into the last_updated_* pair on create). Omitted → attribution NULL.
267
+ createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note>;
237
268
  getNote(id: string): Promise<Note | null>;
238
269
  /**
239
270
  * Look up a note by path. Pass `extension` to disambiguate when
@@ -244,7 +275,7 @@ export interface Store {
244
275
  */
245
276
  getNoteByPath(path: string, extension?: string): Promise<Note | null>;
246
277
  getNotes(ids: string[]): Promise<Note[]>;
247
- updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
278
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
248
279
  /**
249
280
  * Set a note's `created_at` and `updated_at` explicitly. Import-only:
250
281
  * used by the portable-md round-trip path to restore timestamps from
@@ -390,17 +421,7 @@ export interface Store {
390
421
  // `_default` is the implicit universal parent. Returns null when no
391
422
  // ancestor declares any fields. The underlying resolver is in-memory after
392
423
  // the first lazy load.
393
- validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
394
- schemas: string[];
395
- warnings: {
396
- field: string;
397
- schema: string;
398
- reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
399
- message: string;
400
- /** Set only on `schema_conflict` — the tag whose declaration was overridden. */
401
- loser_schema?: string;
402
- }[];
403
- } | null;
424
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null;
404
425
 
405
426
  // Attachments
406
427
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
@@ -369,6 +369,25 @@ export function projectionToMarkdown(args: {
369
369
  lines.push(`- No indexed metadata fields.`);
370
370
  }
371
371
 
372
+ // Strict fields (vault#299): surface the enforced contract so an agent knows
373
+ // BEFORE writing which fields hard-reject on violation (not just warn).
374
+ // Derived from each schema tag's effective fields carrying `strict: true`.
375
+ const strictLines: string[] = [];
376
+ for (const t of projection.tags) {
377
+ const strictNames = Object.entries(t.effective_fields)
378
+ .filter(([, spec]) => (spec as { strict?: boolean }).strict === true)
379
+ .map(([name]) => name);
380
+ if (strictNames.length > 0) {
381
+ strictLines.push(` - #${t.name}: ${strictNames.join(", ")}`);
382
+ }
383
+ }
384
+ if (strictLines.length > 0) {
385
+ lines.push(
386
+ `- Strict fields (writes that violate these are REJECTED, not just warned — enum/type/required/cardinality enforced):`,
387
+ );
388
+ lines.push(...strictLines);
389
+ }
390
+
372
391
  lines.push("");
373
392
  lines.push("## Querying");
374
393
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.3",
3
+ "version": "0.6.4-rc.10",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -38,9 +38,22 @@ describe("isAdminSpaPath", () => {
38
38
  expect(isAdminSpaPath("/vault/work/admin/")).toBe(true);
39
39
  expect(isAdminSpaPath("/vault/work/admin/tokens")).toBe(true);
40
40
  expect(isAdminSpaPath("/vault/boulder/admin/assets/index.js")).toBe(true);
41
- // Vault names with URL-safe punctuation should still match — the regex
42
- // captures up to the next "/" so dashes / dots / digits all pass.
43
- expect(isAdminSpaPath("/vault/my-vault.2/admin")).toBe(true);
41
+ // Canonical-charset names (lowercase alphanumerics + hyphen / underscore)
42
+ // match; dots are NOT canonical (see the vault#253 reject test below).
43
+ expect(isAdminSpaPath("/vault/my-vault_2/admin")).toBe(true);
44
+ });
45
+
46
+ test("vault#253: names outside the canonical charset do NOT match the mount", () => {
47
+ // The mount used to capture `[^/]+`, so a name hub can never manage
48
+ // (dots, @, unicode) still served the admin SPA. Aligning the mount to
49
+ // hub's VAULT_NAME_CHARSET_RE (`/^[a-z0-9_-]+$/`) makes a non-canonical
50
+ // name 404 — the same answer hub gives. No legitimately-created vault can
51
+ // carry these characters (cmdCreate / init / the env var all reject them
52
+ // via validateVaultName), so nothing reachable regresses.
53
+ expect(isAdminSpaPath("/vault/my.vault/admin")).toBe(false);
54
+ expect(isAdminSpaPath("/vault/vault@v2/admin")).toBe(false);
55
+ expect(isAdminSpaPath("/vault/Work/admin")).toBe(false); // uppercase isn't canonical
56
+ expect(isAdminSpaPath("/vault/🦑/admin")).toBe(false);
44
57
  });
45
58
 
46
59
  test("does not match adjacent paths under the same vault", () => {
@@ -111,8 +124,8 @@ describe("serveAdminSpa", () => {
111
124
  expect(cssRes.headers.get("content-type")).toContain("text/css");
112
125
  });
113
126
 
114
- test("vault names with URL-safe punctuation strip cleanly", async () => {
115
- const res = await serveAdminSpa(fixtureDir, "/vault/my-vault.2/admin/assets/index-abc.js");
127
+ test("canonical-charset vault names strip cleanly", async () => {
128
+ const res = await serveAdminSpa(fixtureDir, "/vault/my-vault_2/admin/assets/index-abc.js");
116
129
  expect(res.status).toBe(200);
117
130
  expect(res.headers.get("content-type")).toContain("application/javascript");
118
131
  });