@openparachute/vault 0.6.4-rc.1 → 0.6.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.
- package/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -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 +70 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +50 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- 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-history.test.ts +426 -0
- package/src/mirror-manager.ts +164 -0
- package/src/mirror-routes.ts +182 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +295 -48
- package/src/routing.test.ts +240 -25
- package/src/routing.ts +74 -4
- 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
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
|
@@ -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
|
@@ -28,6 +28,10 @@ import {
|
|
|
28
28
|
type ResolvedSchemas,
|
|
29
29
|
type ValidationStatus,
|
|
30
30
|
} from "./schema-defaults.js";
|
|
31
|
+
import {
|
|
32
|
+
countConformanceViolations,
|
|
33
|
+
type ConformanceReport,
|
|
34
|
+
} from "./conformance.js";
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -100,7 +104,7 @@ export class BunSqliteStore implements Store {
|
|
|
100
104
|
|
|
101
105
|
// ---- Notes ----
|
|
102
106
|
|
|
103
|
-
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
107
|
+
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
108
|
const note = noteOps.createNote(this.db, content, opts);
|
|
105
109
|
|
|
106
110
|
if (content) {
|
|
@@ -140,6 +144,9 @@ export class BunSqliteStore implements Store {
|
|
|
140
144
|
metadata?: Record<string, unknown>;
|
|
141
145
|
created_at?: string;
|
|
142
146
|
skipUpdatedAt?: boolean;
|
|
147
|
+
// Write-attribution (vault#298) — principal + interface of this edit.
|
|
148
|
+
actor?: string | null;
|
|
149
|
+
via?: string | null;
|
|
143
150
|
if_updated_at?: string;
|
|
144
151
|
},
|
|
145
152
|
): Promise<Note> {
|
|
@@ -628,36 +635,65 @@ export class BunSqliteStore implements Store {
|
|
|
628
635
|
const priorRecord =
|
|
629
636
|
patch.fields !== undefined ? tagSchemaOps.getTagRecord(this.db, tag) : null;
|
|
630
637
|
|
|
631
|
-
const
|
|
632
|
-
|
|
638
|
+
const indexedSet = (fields: Record<string, tagSchemaOps.TagFieldSchema> | null | undefined) =>
|
|
639
|
+
new Set(
|
|
640
|
+
Object.entries(fields ?? {})
|
|
641
|
+
.filter(([, v]) => v.indexed === true)
|
|
642
|
+
.map(([k]) => k),
|
|
643
|
+
);
|
|
644
|
+
const nextFields = patch.fields; // object | null | undefined
|
|
645
|
+
const priorIndexed = indexedSet(priorRecord?.fields);
|
|
646
|
+
const nextIndexed = indexedSet(nextFields);
|
|
647
|
+
|
|
648
|
+
// PRE-VALIDATE every newly-indexed field BEFORE any persistence. A bad
|
|
649
|
+
// field name (or unmappable type) must fail closed — the schema record
|
|
650
|
+
// must NOT be written when the backing index can't be created. Pre-checking
|
|
651
|
+
// here, before the transaction even opens, turns the failure into a clean
|
|
652
|
+
// caller error (IndexedFieldError → 400) and leaves the schema untouched.
|
|
653
|
+
// Without this, the prior code persisted the field declaration, THEN threw
|
|
654
|
+
// on declareField — a 500 plus a tag claiming an index the engine can't
|
|
655
|
+
// build (the "lying schema" loop). See vault#478.
|
|
633
656
|
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
657
|
for (const fieldName of nextIndexed) {
|
|
644
658
|
const spec = nextFields![fieldName]!;
|
|
645
659
|
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
660
|
if (!mapped) {
|
|
650
661
|
throw new indexedFieldOps.IndexedFieldError(
|
|
651
662
|
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
652
663
|
);
|
|
653
664
|
}
|
|
654
|
-
|
|
665
|
+
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
666
|
+
indexedFieldOps.validateFieldName(fieldName);
|
|
655
667
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Persist the record + reconcile the indexed-field lifecycle atomically.
|
|
671
|
+
// If declareField throws inside the transaction (e.g. a cross-tag type
|
|
672
|
+
// mismatch only detectable once the existing declarer set is consulted),
|
|
673
|
+
// the whole write rolls back — the schema never ends up claiming an index
|
|
674
|
+
// that doesn't exist. vault#478 transactional fix.
|
|
675
|
+
let result: tagSchemaOps.TagRecord;
|
|
676
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
677
|
+
try {
|
|
678
|
+
result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
679
|
+
|
|
680
|
+
if (patch.fields !== undefined) {
|
|
681
|
+
for (const fieldName of nextIndexed) {
|
|
682
|
+
const spec = nextFields![fieldName]!;
|
|
683
|
+
// Type already validated above; non-null assertion is safe here.
|
|
684
|
+
const mapped = indexedFieldOps.mapFieldType(spec.type)!;
|
|
685
|
+
indexedFieldOps.declareField(this.db, fieldName, mapped, tag);
|
|
686
|
+
}
|
|
687
|
+
for (const fieldName of priorIndexed) {
|
|
688
|
+
if (!nextIndexed.has(fieldName)) {
|
|
689
|
+
indexedFieldOps.releaseField(this.db, fieldName, tag);
|
|
690
|
+
}
|
|
659
691
|
}
|
|
660
692
|
}
|
|
693
|
+
this.db.exec("COMMIT");
|
|
694
|
+
} catch (err) {
|
|
695
|
+
this.db.exec("ROLLBACK");
|
|
696
|
+
throw err;
|
|
661
697
|
}
|
|
662
698
|
|
|
663
699
|
if (patch.parent_names !== undefined) {
|
|
@@ -684,6 +720,21 @@ export class BunSqliteStore implements Store {
|
|
|
684
720
|
return result;
|
|
685
721
|
}
|
|
686
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Conformance check (vault#283) — count how many EXISTING notes carrying
|
|
725
|
+
* `tag` (descendants included) would violate the PROPOSED field spec, so a
|
|
726
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
727
|
+
* warn the operator BEFORE save. Pure read — no mutation. See
|
|
728
|
+
* core/src/conformance.ts.
|
|
729
|
+
*/
|
|
730
|
+
async countTagConformance(
|
|
731
|
+
tag: string,
|
|
732
|
+
proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
|
|
733
|
+
opts?: { sampleLimit?: number },
|
|
734
|
+
): Promise<ConformanceReport> {
|
|
735
|
+
return countConformanceViolations(this.db, tag, proposedFields, opts);
|
|
736
|
+
}
|
|
737
|
+
|
|
687
738
|
// ---- Batch Wikilink Sync ----
|
|
688
739
|
|
|
689
740
|
/**
|
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,12 +2,15 @@ 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";
|
|
6
|
+
import type { ConformanceReport } from "./conformance.js";
|
|
5
7
|
|
|
6
8
|
// ---- Re-exports ----
|
|
7
9
|
|
|
8
10
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
9
11
|
export type { PrunedField } from "./indexed-fields.js";
|
|
10
12
|
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { ConformanceReport } from "./conformance.js";
|
|
11
14
|
|
|
12
15
|
// ---- Note ----
|
|
13
16
|
|
|
@@ -26,6 +29,19 @@ export interface Note {
|
|
|
26
29
|
metadata?: Record<string, unknown>;
|
|
27
30
|
createdAt: string; // ISO-8601
|
|
28
31
|
updatedAt?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Write-attribution (vault#298) — two axes of provenance, both nullable.
|
|
34
|
+
* `*By` is the principal (a JWT `sub`, or an operator / `token:<id>` label);
|
|
35
|
+
* `*Via` is the interface the write arrived through (`mcp`, `surface:<name>`,
|
|
36
|
+
* `agent:<id>`, `operator`/`cli`, `api`). The `created*` pair is set once at
|
|
37
|
+
* create; the `lastUpdated*` pair tracks the most recent mutating write. NULL
|
|
38
|
+
* = unknown / written before attribution existed (legacy rows) or by a path
|
|
39
|
+
* that carried no context — distinct from any real principal.
|
|
40
|
+
*/
|
|
41
|
+
createdBy?: string | null;
|
|
42
|
+
createdVia?: string | null;
|
|
43
|
+
lastUpdatedBy?: string | null;
|
|
44
|
+
lastUpdatedVia?: string | null;
|
|
29
45
|
tags?: string[];
|
|
30
46
|
links?: Link[];
|
|
31
47
|
/**
|
|
@@ -125,6 +141,15 @@ export interface QueryOpts {
|
|
|
125
141
|
// for the field. Operator queries require the field to be declared
|
|
126
142
|
// `indexed: true` in a tag schema; undeclared fields error loudly.
|
|
127
143
|
metadata?: Record<string, unknown>;
|
|
144
|
+
// Write-attribution filters (vault#298). Exact-match on the indexed
|
|
145
|
+
// attribution columns — "what did Mathilda write" (`createdBy`/`lastUpdatedBy`)
|
|
146
|
+
// or "what came in via the meeting-ingest surface"
|
|
147
|
+
// (`createdVia`/`lastUpdatedVia`). Each is an exact string match; multiple
|
|
148
|
+
// AND together with the rest of the filter set.
|
|
149
|
+
createdBy?: string;
|
|
150
|
+
lastUpdatedBy?: string;
|
|
151
|
+
createdVia?: string;
|
|
152
|
+
lastUpdatedVia?: string;
|
|
128
153
|
// Legacy shorthand: filters on `n.created_at` (vault ingestion time).
|
|
129
154
|
// Equivalent to `dateFilter: { field: "created_at", from, to }`. Kept
|
|
130
155
|
// as the common path; specifying both this and `dateFilter` rejects.
|
|
@@ -206,6 +231,12 @@ export interface NoteIndex {
|
|
|
206
231
|
extension?: string;
|
|
207
232
|
createdAt: string;
|
|
208
233
|
updatedAt?: string;
|
|
234
|
+
/** Write-attribution (vault#298) — carried on the lean shape too so "who
|
|
235
|
+
* touched what" is answerable without re-fetching full content. See `Note`. */
|
|
236
|
+
createdBy?: string | null;
|
|
237
|
+
createdVia?: string | null;
|
|
238
|
+
lastUpdatedBy?: string | null;
|
|
239
|
+
lastUpdatedVia?: string | null;
|
|
209
240
|
tags?: string[];
|
|
210
241
|
metadata?: Record<string, unknown>;
|
|
211
242
|
byteSize: number;
|
|
@@ -232,8 +263,10 @@ export interface Store {
|
|
|
232
263
|
*/
|
|
233
264
|
readonly db: Database;
|
|
234
265
|
|
|
235
|
-
// Notes
|
|
236
|
-
|
|
266
|
+
// Notes. `actor` / `via` carry write-attribution (vault#298) — the
|
|
267
|
+
// principal + interface stamped onto created_by/created_via (and mirrored
|
|
268
|
+
// into the last_updated_* pair on create). Omitted → attribution NULL.
|
|
269
|
+
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
270
|
getNote(id: string): Promise<Note | null>;
|
|
238
271
|
/**
|
|
239
272
|
* Look up a note by path. Pass `extension` to disambiguate when
|
|
@@ -244,7 +277,7 @@ export interface Store {
|
|
|
244
277
|
*/
|
|
245
278
|
getNoteByPath(path: string, extension?: string): Promise<Note | null>;
|
|
246
279
|
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>;
|
|
280
|
+
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
281
|
/**
|
|
249
282
|
* Set a note's `created_at` and `updated_at` explicitly. Import-only:
|
|
250
283
|
* used by the portable-md round-trip path to restore timestamps from
|
|
@@ -382,6 +415,19 @@ export interface Store {
|
|
|
382
415
|
},
|
|
383
416
|
): Promise<TagRecord>;
|
|
384
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Conformance check (vault#283) — count existing notes carrying `tag`
|
|
420
|
+
* (descendants included) that would violate the PROPOSED field spec, so a
|
|
421
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
422
|
+
* warn before save. Pure read. `proposedFields` is the full merged field
|
|
423
|
+
* map the operator intends to save; only those fields are checked.
|
|
424
|
+
*/
|
|
425
|
+
countTagConformance(
|
|
426
|
+
tag: string,
|
|
427
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
428
|
+
opts?: { sampleLimit?: number },
|
|
429
|
+
): Promise<ConformanceReport>;
|
|
430
|
+
|
|
385
431
|
// Schema validation (post-v17: backed by `tags.fields` only — the
|
|
386
432
|
// standalone note_schemas + schema_mappings subsystem retired in v17, see
|
|
387
433
|
// vault#267). Post vault#270 the resolver walks `parent_names` so a note's
|
|
@@ -390,17 +436,7 @@ export interface Store {
|
|
|
390
436
|
// `_default` is the implicit universal parent. Returns null when no
|
|
391
437
|
// ancestor declares any fields. The underlying resolver is in-memory after
|
|
392
438
|
// 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;
|
|
439
|
+
validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null;
|
|
404
440
|
|
|
405
441
|
// Attachments
|
|
406
442
|
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("");
|