@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.
- package/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- 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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
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
|
+
}
|
package/core/src/schema.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
661
|
+
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
662
|
+
indexedFieldOps.validateFieldName(fieldName);
|
|
655
663
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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) {
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/src/admin-spa.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
expect(isAdminSpaPath("/vault/my-
|
|
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
|
|
115
|
-
const res = await serveAdminSpa(fixtureDir, "/vault/my-
|
|
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
|
});
|