@openparachute/vault 0.6.4-rc.2 → 0.6.4-rc.4

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/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,
@@ -130,6 +135,28 @@ export interface GenerateMcpToolsOpts {
130
135
  * Omitted (internal / unattributed callers) → writes leave attribution NULL.
131
136
  */
132
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;
133
160
  expandVisibility?: (note: Note) => boolean;
134
161
  /**
135
162
  * `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
@@ -156,6 +183,39 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
156
183
  // and folded into every create/update the tools perform.
157
184
  const writeActor = opts?.writeContext?.actor ?? null;
158
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
+ };
159
219
 
160
220
  return [
161
221
 
@@ -661,6 +721,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
661
721
  const extension = item.extension !== undefined
662
722
  ? validateExtension(item.extension)
663
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
+ });
664
731
  const note = await store.createNote(item.content as string ?? "", {
665
732
  path: item.path as string | undefined,
666
733
  tags: item.tags as string[] | undefined,
@@ -768,6 +835,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
768
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." },
769
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." },
770
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
+ },
771
848
  tags: {
772
849
  type: "object",
773
850
  properties: {
@@ -837,6 +914,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
837
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." },
838
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." },
839
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
+ },
840
927
  tags: { type: "object" },
841
928
  links: { type: "object" },
842
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." },
@@ -951,6 +1038,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
951
1038
  via: writeVia,
952
1039
  };
953
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
+ });
954
1049
  const created = await store.createNote(content, createOpts);
955
1050
  await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
956
1051
  // Apply links.add if the caller declared any.
@@ -1009,7 +1104,19 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1009
1104
  && item.created_at === undefined
1010
1105
  && item.tags === undefined
1011
1106
  && item.links === undefined;
1012
- if (!isAppendOnly && item.if_updated_at === undefined && item.force !== true) {
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) {
1013
1120
  throw new PreconditionRequiredError(note.id, note.path ?? null);
1014
1121
  }
1015
1122
 
@@ -1093,6 +1200,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1093
1200
  }
1094
1201
  if (item.created_at !== undefined) updates.created_at = item.created_at;
1095
1202
  if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
1203
+ // Compare-and-set state transition (vault#299 Part B). Combinable
1204
+ // with other field updates — it folds into the same atomic UPDATE.
1205
+ const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
1206
+ if (stItem !== undefined) {
1207
+ if (typeof stItem.field !== "string" || stItem.field.length === 0) {
1208
+ throw new Error(
1209
+ `update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
1210
+ );
1211
+ }
1212
+ updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
1213
+ }
1214
+
1215
+ // --- Strict-schema gate (vault#299 Part A) ---
1216
+ // Validate the PROSPECTIVE shape (final tags + merged metadata,
1217
+ // including a state_transition's `to`) before the write so a
1218
+ // rejection leaves the note untouched.
1219
+ {
1220
+ const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
1221
+ const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
1222
+ for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
1223
+ const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
1224
+ const projectedMeta = stItem !== undefined
1225
+ ? { ...baseMeta, [stItem.field as string]: stItem.to }
1226
+ : baseMeta;
1227
+ enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1228
+ }
1096
1229
 
1097
1230
  let result: Note;
1098
1231
  if (Object.keys(updates).length > 0) {
@@ -1287,14 +1420,17 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1287
1420
  description: { type: "string", description: "Human-readable description of what this tag means" },
1288
1421
  fields: {
1289
1422
  type: "object",
1290
- description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"] } }',
1423
+ 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).',
1291
1424
  additionalProperties: {
1292
1425
  type: "object",
1293
1426
  properties: {
1294
- type: { type: "string", description: "Field type: string, boolean, integer" },
1427
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
1295
1428
  description: { type: "string" },
1296
1429
  enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default)" },
1297
1430
  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." },
1431
+ 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." },
1432
+ required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
1433
+ cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
1298
1434
  },
1299
1435
  required: ["type"],
1300
1436
  },
@@ -1545,6 +1681,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1545
1681
  * actually written — callers use this to re-read ONLY the mutated notes (and
1546
1682
  * to skip the re-read entirely when nothing changed). The common no-schema /
1547
1683
  * no-defaults path returns an empty array.
1684
+ *
1685
+ * vault#299: this runs AFTER the create write (so AFTER the strict gate) and
1686
+ * intentionally does NOT re-run `enforceStrict`. Defaults are always
1687
+ * conforming by construction — `defaultForField` returns the first enum value
1688
+ * / the type's zero-value, so a default can never violate type/enum. And a
1689
+ * `required` strict field is already caught at the pre-write gate, so a note
1690
+ * that would need a default to satisfy `required` never reaches this filler
1691
+ * (the create was rejected first). Don't add a defaults path that could
1692
+ * inject a violating value without re-gating.
1548
1693
  */
1549
1694
  async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1550
1695
  const schemas = tagSchemaOps.getTagSchemaMap(db);
@@ -1610,6 +1755,36 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
1610
1755
  * the same recipe — see vault#287 for the asymmetry that motivated
1611
1756
  * exposing it.
1612
1757
  */
1758
+ /**
1759
+ * Pre-write strict-schema gate (vault#299 Part A). Shared by both write
1760
+ * transports (MCP tools here, REST PATCH/POST in `src/routes.ts`) so the
1761
+ * enforcement contract can't drift between them — the same recipe the
1762
+ * `validation_status` attachment shares via `attachValidationStatus`.
1763
+ *
1764
+ * Validates the PROSPECTIVE note shape (final tags + merged metadata) against
1765
+ * the resolved schemas and:
1766
+ * - no strict violations → no-op, the write proceeds.
1767
+ * - violations + `bypass:false` → throw `SchemaValidationError` (one error,
1768
+ * all per-field violations — settled lead #1). Caller writes nothing.
1769
+ * - violations + `bypass:true` → invoke `onBypass(violations)` (migration
1770
+ * scope) and return; the caller proceeds with the non-conforming write.
1771
+ *
1772
+ * Returns the would-be violations (empty when none) so a caller can inspect
1773
+ * them; the throw / bypass decision is already made internally.
1774
+ */
1775
+ export function enforceStrictWrite(
1776
+ store: Store,
1777
+ shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
1778
+ opts?: { bypass?: boolean; onBypass?: (violations: ValidationWarning[]) => void },
1779
+ ): ValidationWarning[] {
1780
+ const status = store.validateNoteAgainstSchemas(shape);
1781
+ const violations = strictViolations(status);
1782
+ if (violations.length === 0) return [];
1783
+ if (opts?.bypass !== true) throw new SchemaValidationError(violations);
1784
+ opts.onBypass?.(violations);
1785
+ return violations;
1786
+ }
1787
+
1613
1788
  export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1614
1789
  // Short-circuit cheaply: when no tag declares fields, the resolver
1615
1790
  // returns null without us paying a re-read of the note.
@@ -1649,7 +1824,11 @@ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound
1649
1824
  // conditional-UPDATE implementation that raises it. AmbiguousPathError
1650
1825
  // joins the set (vault#331 N2) so external callers can `instanceof`
1651
1826
  // it without crossing module boundaries.
1652
- export { ConflictError, PathConflictError, AmbiguousPathError, MAX_BATCH_SIZE } from "./notes.js";
1827
+ export { ConflictError, PathConflictError, AmbiguousPathError, TransitionConflictError, MAX_BATCH_SIZE } from "./notes.js";
1828
+ // vault#299: strict-schema enforcement error, re-exported alongside the other
1829
+ // write-path domain errors so external callers can `instanceof` it without
1830
+ // crossing module boundaries.
1831
+ export { SchemaValidationError } from "./schema-defaults.js";
1653
1832
 
1654
1833
  /**
1655
1834
  * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a