@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
package/core/src/mcp.ts
CHANGED
|
@@ -8,6 +8,11 @@ import * as linkOps from "./links.js";
|
|
|
8
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
9
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
10
10
|
import * as indexedFieldOps from "./indexed-fields.js";
|
|
11
|
+
import {
|
|
12
|
+
SchemaValidationError,
|
|
13
|
+
strictViolations,
|
|
14
|
+
type ValidationWarning,
|
|
15
|
+
} from "./schema-defaults.js";
|
|
11
16
|
import {
|
|
12
17
|
expandContent,
|
|
13
18
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -120,6 +125,38 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
120
125
|
* expansion behaves exactly as before.
|
|
121
126
|
*/
|
|
122
127
|
export interface GenerateMcpToolsOpts {
|
|
128
|
+
/**
|
|
129
|
+
* Write-attribution context (vault#298) stamped onto every note written
|
|
130
|
+
* through these tools. `actor` is the principal (JWT `sub` / operator
|
|
131
|
+
* label); `via` is the interface the write arrived through (here, always an
|
|
132
|
+
* MCP session — the server-side wrapper derives `mcp` or a more specific
|
|
133
|
+
* `agent:<id>` / `surface:<name>` when the token's claims reveal it). The
|
|
134
|
+
* core tools pass it straight into `store.createNote` / `store.updateNote`.
|
|
135
|
+
* Omitted (internal / unattributed callers) → writes leave attribution NULL.
|
|
136
|
+
*/
|
|
137
|
+
writeContext?: { actor?: string | null; via?: string | null };
|
|
138
|
+
/**
|
|
139
|
+
* Strict-schema enforcement controls (vault#299 Part A). By default every
|
|
140
|
+
* write through these tools enforces `strict:true` field constraints — a
|
|
141
|
+
* violation throws `SchemaValidationError` and the note is NOT written.
|
|
142
|
+
*
|
|
143
|
+
* `strictBypass: true` — the caller holds the migration-bypass scope
|
|
144
|
+
* (`vault:migrate`); skip enforcement so non-conforming notes can be
|
|
145
|
+
* migrated/backfilled. Every bypassed write that WOULD have been
|
|
146
|
+
* rejected calls `onStrictBypass` for logging (the audit-log table,
|
|
147
|
+
* #300, is deferred — we log to the daemon's structured log for now).
|
|
148
|
+
* `onStrictBypass` — invoked once per bypassed write with the would-be
|
|
149
|
+
* violations plus the actor/via from `writeContext`. Server-layer
|
|
150
|
+
* supplies a structured logger; core stays log-sink-agnostic.
|
|
151
|
+
*/
|
|
152
|
+
strictBypass?: boolean;
|
|
153
|
+
onStrictBypass?: (info: {
|
|
154
|
+
actor: string | null;
|
|
155
|
+
via: string | null;
|
|
156
|
+
path?: string | null;
|
|
157
|
+
tags?: string[];
|
|
158
|
+
violations: ValidationWarning[];
|
|
159
|
+
}) => void;
|
|
123
160
|
expandVisibility?: (note: Note) => boolean;
|
|
124
161
|
/**
|
|
125
162
|
* `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
|
|
@@ -141,6 +178,44 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
141
178
|
const db: Database = store.db;
|
|
142
179
|
const expandVisibility = opts?.expandVisibility;
|
|
143
180
|
const nearTraversable = opts?.nearTraversable;
|
|
181
|
+
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
182
|
+
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
183
|
+
// and folded into every create/update the tools perform.
|
|
184
|
+
const writeActor = opts?.writeContext?.actor ?? null;
|
|
185
|
+
const writeVia = opts?.writeContext?.via ?? null;
|
|
186
|
+
const strictBypass = opts?.strictBypass === true;
|
|
187
|
+
const onStrictBypass = opts?.onStrictBypass;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Pre-write strict-schema gate (vault#299 Part A). Validate the PROSPECTIVE
|
|
191
|
+
* note shape (final tags + merged metadata) against the resolved schemas.
|
|
192
|
+
* - No strict violations → no-op (the write proceeds; advisory warnings
|
|
193
|
+
* still surface later via `attachValidationStatus`).
|
|
194
|
+
* - Strict violations + no bypass → throw `SchemaValidationError` (single
|
|
195
|
+
* error, all per-field violations) so nothing is written.
|
|
196
|
+
* - Strict violations + bypass → log via `onStrictBypass` and proceed.
|
|
197
|
+
* Called immediately before `store.createNote` / `store.updateNote` so a
|
|
198
|
+
* rejection leaves the note untouched.
|
|
199
|
+
*/
|
|
200
|
+
const enforceStrict = (shape: {
|
|
201
|
+
path?: string | null;
|
|
202
|
+
tags?: string[];
|
|
203
|
+
metadata?: Record<string, unknown>;
|
|
204
|
+
}): void => {
|
|
205
|
+
enforceStrictWrite(store, shape, {
|
|
206
|
+
bypass: strictBypass,
|
|
207
|
+
onBypass: onStrictBypass
|
|
208
|
+
? (violations) =>
|
|
209
|
+
onStrictBypass({
|
|
210
|
+
actor: writeActor,
|
|
211
|
+
via: writeVia,
|
|
212
|
+
path: shape.path ?? null,
|
|
213
|
+
tags: shape.tags,
|
|
214
|
+
violations,
|
|
215
|
+
})
|
|
216
|
+
: undefined,
|
|
217
|
+
});
|
|
218
|
+
};
|
|
144
219
|
|
|
145
220
|
return [
|
|
146
221
|
|
|
@@ -220,6 +295,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
220
295
|
type: "object",
|
|
221
296
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
222
297
|
},
|
|
298
|
+
created_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose FIRST write was attributed to this principal (a JWT subject, or an operator/token label). Exact match; indexed. Legacy/unattributed notes (NULL) never match." },
|
|
299
|
+
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
300
|
+
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `operator`, `api`. Exact match; indexed." },
|
|
301
|
+
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel. Exact match; indexed." },
|
|
223
302
|
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
224
303
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
225
304
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
@@ -460,6 +539,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
460
539
|
// result whenever the neighborhood lies outside that prefix.
|
|
461
540
|
ids: nearScope ? [...nearScope] : undefined,
|
|
462
541
|
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
542
|
+
// Write-attribution filters (vault#298): "who wrote / via what."
|
|
543
|
+
createdBy: params.created_by as string | undefined,
|
|
544
|
+
lastUpdatedBy: params.last_updated_by as string | undefined,
|
|
545
|
+
createdVia: params.created_via as string | undefined,
|
|
546
|
+
lastUpdatedVia: params.last_updated_via as string | undefined,
|
|
463
547
|
dateFrom: params.date_from as string | undefined,
|
|
464
548
|
dateTo: params.date_to as string | undefined,
|
|
465
549
|
dateFilter: params.date_filter as
|
|
@@ -637,12 +721,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
637
721
|
const extension = item.extension !== undefined
|
|
638
722
|
? validateExtension(item.extension)
|
|
639
723
|
: undefined;
|
|
724
|
+
// Strict-schema gate (vault#299) — reject before any write so a
|
|
725
|
+
// mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
|
|
726
|
+
enforceStrict({
|
|
727
|
+
path: item.path as string | undefined,
|
|
728
|
+
tags: item.tags as string[] | undefined,
|
|
729
|
+
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
730
|
+
});
|
|
640
731
|
const note = await store.createNote(item.content as string ?? "", {
|
|
641
732
|
path: item.path as string | undefined,
|
|
642
733
|
tags: item.tags as string[] | undefined,
|
|
643
734
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
644
735
|
created_at: item.created_at as string | undefined,
|
|
645
736
|
...(extension !== undefined ? { extension } : {}),
|
|
737
|
+
// Write-attribution (vault#298) — same actor/via for every item
|
|
738
|
+
// in a batch (the whole call came from one authenticated session).
|
|
739
|
+
actor: writeActor,
|
|
740
|
+
via: writeVia,
|
|
646
741
|
});
|
|
647
742
|
|
|
648
743
|
// Create explicit links (not wikilinks — those are automatic)
|
|
@@ -714,7 +809,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
714
809
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
715
810
|
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
716
811
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
717
|
-
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present
|
|
812
|
+
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
|
|
813
|
+
|
|
814
|
+
Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
|
|
718
815
|
inputSchema: {
|
|
719
816
|
type: "object",
|
|
720
817
|
properties: {
|
|
@@ -733,11 +830,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
733
830
|
},
|
|
734
831
|
path: { type: "string", description: "New path" },
|
|
735
832
|
extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
|
|
736
|
-
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
833
|
+
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale). A value of `null` deletes that key (RFC 7386 merge-patch) — e.g. `{\"new_key\": \"v\", \"old_key\": null}` renames in one call. Omitting a key preserves its existing value." },
|
|
737
834
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
738
835
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
739
836
|
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
740
837
|
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
838
|
+
state_transition: {
|
|
839
|
+
type: "object",
|
|
840
|
+
properties: {
|
|
841
|
+
field: { type: "string", description: "Metadata field to transition." },
|
|
842
|
+
from: { description: "Required current value. The transition only commits if the field currently equals this. A missing field is a conflict; pass `null` to match a field that is absent or explicitly null." },
|
|
843
|
+
to: { description: "New value to set when the `from` precondition holds." },
|
|
844
|
+
},
|
|
845
|
+
required: ["field", "from", "to"],
|
|
846
|
+
description: "Atomic compare-and-set state transition (vault#299). If the metadata `field` currently equals `from`, set it to `to` and commit; otherwise the write is rejected with a `transition_conflict` error (a missing field counts as a conflict; `from: null` matches absent-or-null). A transition-ONLY update needs no `if_updated_at`/`force` — the compare-and-set is the precondition. Combinable with other field updates (they land in the same atomic UPDATE), but a combined call still needs `if_updated_at`/`force` for the OTHER fields — the CAS only guards the transitioned field. Use this to advance a state machine race-safely in one round trip instead of read → check → conditional update.",
|
|
847
|
+
},
|
|
741
848
|
tags: {
|
|
742
849
|
type: "object",
|
|
743
850
|
properties: {
|
|
@@ -807,6 +914,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
807
914
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
808
915
|
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
|
|
809
916
|
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
917
|
+
state_transition: {
|
|
918
|
+
type: "object",
|
|
919
|
+
properties: {
|
|
920
|
+
field: { type: "string" },
|
|
921
|
+
from: {},
|
|
922
|
+
to: {},
|
|
923
|
+
},
|
|
924
|
+
required: ["field", "from", "to"],
|
|
925
|
+
description: "Per-item compare-and-set state transition (vault#299). See top-level `state_transition` docs.",
|
|
926
|
+
},
|
|
810
927
|
tags: { type: "object" },
|
|
811
928
|
links: { type: "object" },
|
|
812
929
|
include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
|
|
@@ -913,8 +1030,22 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
913
1030
|
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
914
1031
|
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
915
1032
|
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
1033
|
+
// Write-attribution (vault#298) — the if_missing:"create" upsert
|
|
1034
|
+
// branch is still a CREATE, so it must stamp the same actor/via
|
|
1035
|
+
// as the create-note tool + the REST upsert-create path. Without
|
|
1036
|
+
// this an MCP-driven upsert-create wrote NULL attribution.
|
|
1037
|
+
actor: writeActor,
|
|
1038
|
+
via: writeVia,
|
|
916
1039
|
};
|
|
917
1040
|
const content = (item.content as string | undefined) ?? "";
|
|
1041
|
+
// Strict-schema gate (vault#299) — the if_missing:"create"
|
|
1042
|
+
// branch is still a create, so it enforces too. Tags come from
|
|
1043
|
+
// createOpts (already normalized from the {add} dict / array).
|
|
1044
|
+
enforceStrict({
|
|
1045
|
+
path: createOpts.path ?? undefined,
|
|
1046
|
+
tags: createOpts.tags,
|
|
1047
|
+
metadata: createOpts.metadata,
|
|
1048
|
+
});
|
|
918
1049
|
const created = await store.createNote(content, createOpts);
|
|
919
1050
|
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
920
1051
|
// Apply links.add if the caller declared any.
|
|
@@ -973,7 +1104,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
973
1104
|
&& item.created_at === undefined
|
|
974
1105
|
&& item.tags === undefined
|
|
975
1106
|
&& item.links === undefined;
|
|
976
|
-
|
|
1107
|
+
// A state_transition is itself a compare-and-set precondition
|
|
1108
|
+
// (vault#299 Part B) — a transition-only update doesn't need
|
|
1109
|
+
// `if_updated_at`/`force`, the CAS guards the lost-write window.
|
|
1110
|
+
const isTransitionOnly = item.state_transition !== undefined
|
|
1111
|
+
&& !hasContent
|
|
1112
|
+
&& !hasAppendPrepend
|
|
1113
|
+
&& !hasContentEdit
|
|
1114
|
+
&& item.path === undefined
|
|
1115
|
+
&& item.metadata === undefined
|
|
1116
|
+
&& item.created_at === undefined
|
|
1117
|
+
&& item.tags === undefined
|
|
1118
|
+
&& item.links === undefined;
|
|
1119
|
+
if (!isAppendOnly && !isTransitionOnly && item.if_updated_at === undefined && item.force !== true) {
|
|
977
1120
|
throw new PreconditionRequiredError(note.id, note.path ?? null);
|
|
978
1121
|
}
|
|
979
1122
|
|
|
@@ -1051,15 +1194,52 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1051
1194
|
updates.extension = validateExtension(item.extension);
|
|
1052
1195
|
}
|
|
1053
1196
|
if (item.metadata !== undefined) {
|
|
1054
|
-
// Merge metadata (
|
|
1055
|
-
|
|
1056
|
-
|
|
1197
|
+
// Merge metadata (RFC 7386: keys are merged, incoming `null`
|
|
1198
|
+
// removes the key rather than persisting a literal null —
|
|
1199
|
+
// vault#478/#479). Mirrors the REST PATCH path.
|
|
1200
|
+
updates.metadata = noteOps.mergeMetadata(
|
|
1201
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1202
|
+
item.metadata as Record<string, unknown>,
|
|
1203
|
+
);
|
|
1057
1204
|
}
|
|
1058
1205
|
if (item.created_at !== undefined) updates.created_at = item.created_at;
|
|
1059
1206
|
if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
|
|
1207
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable
|
|
1208
|
+
// with other field updates — it folds into the same atomic UPDATE.
|
|
1209
|
+
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1210
|
+
if (stItem !== undefined) {
|
|
1211
|
+
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1212
|
+
throw new Error(
|
|
1213
|
+
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1220
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata,
|
|
1221
|
+
// including a state_transition's `to`) before the write so a
|
|
1222
|
+
// rejection leaves the note untouched.
|
|
1223
|
+
{
|
|
1224
|
+
const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
|
|
1225
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1226
|
+
for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1227
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1228
|
+
const projectedMeta = stItem !== undefined
|
|
1229
|
+
? { ...baseMeta, [stItem.field as string]: stItem.to }
|
|
1230
|
+
: baseMeta;
|
|
1231
|
+
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1232
|
+
}
|
|
1060
1233
|
|
|
1061
1234
|
let result: Note;
|
|
1062
1235
|
if (Object.keys(updates).length > 0) {
|
|
1236
|
+
// Write-attribution (vault#298): stamp the most-recent-write
|
|
1237
|
+
// columns on the same UPDATE that bumps `updated_at`. Only set when
|
|
1238
|
+
// there's a real change to write (the empty-updates branch below
|
|
1239
|
+
// leaves attribution untouched, symmetric with not bumping
|
|
1240
|
+
// updated_at on a no-op).
|
|
1241
|
+
updates.actor = writeActor;
|
|
1242
|
+
updates.via = writeVia;
|
|
1063
1243
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1064
1244
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1065
1245
|
// throws ConflictError on mismatch. No mutations have happened
|
|
@@ -1244,14 +1424,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1244
1424
|
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
1245
1425
|
fields: {
|
|
1246
1426
|
type: "object",
|
|
1247
|
-
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"] } }',
|
|
1427
|
+
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
|
|
1248
1428
|
additionalProperties: {
|
|
1249
1429
|
type: "object",
|
|
1250
1430
|
properties: {
|
|
1251
|
-
type: { type: "string", description: "Field type: string, boolean, integer" },
|
|
1431
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
|
|
1252
1432
|
description: { type: "string" },
|
|
1253
1433
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
|
|
1254
1434
|
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed." },
|
|
1435
|
+
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off." },
|
|
1436
|
+
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
1437
|
+
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
1255
1438
|
},
|
|
1256
1439
|
required: ["type"],
|
|
1257
1440
|
},
|
|
@@ -1502,6 +1685,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1502
1685
|
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1503
1686
|
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1504
1687
|
* no-defaults path returns an empty array.
|
|
1688
|
+
*
|
|
1689
|
+
* vault#299: this runs AFTER the create write (so AFTER the strict gate) and
|
|
1690
|
+
* intentionally does NOT re-run `enforceStrict`. Defaults are always
|
|
1691
|
+
* conforming by construction — `defaultForField` returns the first enum value
|
|
1692
|
+
* / the type's zero-value, so a default can never violate type/enum. And a
|
|
1693
|
+
* `required` strict field is already caught at the pre-write gate, so a note
|
|
1694
|
+
* that would need a default to satisfy `required` never reaches this filler
|
|
1695
|
+
* (the create was rejected first). Don't add a defaults path that could
|
|
1696
|
+
* inject a violating value without re-gating.
|
|
1505
1697
|
*/
|
|
1506
1698
|
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1507
1699
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
@@ -1567,6 +1759,36 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
|
1567
1759
|
* the same recipe — see vault#287 for the asymmetry that motivated
|
|
1568
1760
|
* exposing it.
|
|
1569
1761
|
*/
|
|
1762
|
+
/**
|
|
1763
|
+
* Pre-write strict-schema gate (vault#299 Part A). Shared by both write
|
|
1764
|
+
* transports (MCP tools here, REST PATCH/POST in `src/routes.ts`) so the
|
|
1765
|
+
* enforcement contract can't drift between them — the same recipe the
|
|
1766
|
+
* `validation_status` attachment shares via `attachValidationStatus`.
|
|
1767
|
+
*
|
|
1768
|
+
* Validates the PROSPECTIVE note shape (final tags + merged metadata) against
|
|
1769
|
+
* the resolved schemas and:
|
|
1770
|
+
* - no strict violations → no-op, the write proceeds.
|
|
1771
|
+
* - violations + `bypass:false` → throw `SchemaValidationError` (one error,
|
|
1772
|
+
* all per-field violations — settled lead #1). Caller writes nothing.
|
|
1773
|
+
* - violations + `bypass:true` → invoke `onBypass(violations)` (migration
|
|
1774
|
+
* scope) and return; the caller proceeds with the non-conforming write.
|
|
1775
|
+
*
|
|
1776
|
+
* Returns the would-be violations (empty when none) so a caller can inspect
|
|
1777
|
+
* them; the throw / bypass decision is already made internally.
|
|
1778
|
+
*/
|
|
1779
|
+
export function enforceStrictWrite(
|
|
1780
|
+
store: Store,
|
|
1781
|
+
shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
1782
|
+
opts?: { bypass?: boolean; onBypass?: (violations: ValidationWarning[]) => void },
|
|
1783
|
+
): ValidationWarning[] {
|
|
1784
|
+
const status = store.validateNoteAgainstSchemas(shape);
|
|
1785
|
+
const violations = strictViolations(status);
|
|
1786
|
+
if (violations.length === 0) return [];
|
|
1787
|
+
if (opts?.bypass !== true) throw new SchemaValidationError(violations);
|
|
1788
|
+
opts.onBypass?.(violations);
|
|
1789
|
+
return violations;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1570
1792
|
export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1571
1793
|
// Short-circuit cheaply: when no tag declares fields, the resolver
|
|
1572
1794
|
// returns null without us paying a re-read of the note.
|
|
@@ -1606,7 +1828,11 @@ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound
|
|
|
1606
1828
|
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1607
1829
|
// joins the set (vault#331 N2) so external callers can `instanceof`
|
|
1608
1830
|
// it without crossing module boundaries.
|
|
1609
|
-
export { ConflictError, PathConflictError, AmbiguousPathError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1831
|
+
export { ConflictError, PathConflictError, AmbiguousPathError, TransitionConflictError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1832
|
+
// vault#299: strict-schema enforcement error, re-exported alongside the other
|
|
1833
|
+
// write-path domain errors so external callers can `instanceof` it without
|
|
1834
|
+
// crossing module boundaries.
|
|
1835
|
+
export { SchemaValidationError } from "./schema-defaults.js";
|
|
1610
1836
|
|
|
1611
1837
|
/**
|
|
1612
1838
|
* Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
|