@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.6

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.
@@ -60,6 +60,20 @@ export interface SchemaField {
60
60
  type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
61
61
  enum?: string[];
62
62
  description?: string;
63
+ /**
64
+ * Mirrors `TagFieldSchema.indexed` (core/src/tag-schemas.ts) — this field
65
+ * has a generated column + B-tree index maintained on `notes` (see
66
+ * core/src/indexed-fields.ts). Parsed here (not just there) because
67
+ * `validateNote` uses it: an indexed field is a QUERY CONTRACT, not just a
68
+ * storage hint, so a `type_mismatch` on an indexed field is always
69
+ * enforced (`strict: true` on that ONE warning) regardless of this field's
70
+ * own `strict` flag (vault#553 Decision A). Every other constraint
71
+ * (enum/required/cardinality) on an indexed field stays governed by
72
+ * `strict` as before — only the TYPE contract is unconditional, because
73
+ * that's the one a type-mismatched write can silently poison range
74
+ * queries with (SQLite's TEXT-sorts-above-INTEGER affinity ordering).
75
+ */
76
+ indexed?: boolean;
63
77
  /**
64
78
  * Strict enforcement opt-in (vault#299, Part A). Default `false` — when
65
79
  * unset/false, ALL constraints on this field are advisory: violations
@@ -169,6 +183,7 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
169
183
  if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
170
184
  if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
171
185
  if (typeof f.description === "string") field.description = f.description;
186
+ if (f.indexed === true) field.indexed = true;
172
187
  if (f.strict === true) field.strict = true;
173
188
  if (f.required === true) field.required = true;
174
189
  if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
@@ -319,6 +334,20 @@ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): bo
319
334
  return true;
320
335
  }
321
336
 
337
+ /**
338
+ * Human-readable JSON-shape name for a value, used to name the "got" side of
339
+ * a `type_mismatch` message (vault#553 — "message names field + expected
340
+ * type + got type"). Distinct from SQLite's `json_type()` vocabulary
341
+ * (integer/real/true/false/text/...) — this is the JSON-Schema-ish vocabulary
342
+ * `SchemaField.type` already uses, so the message reads as "expected X, got
343
+ * Y" in the SAME words the schema declares.
344
+ */
345
+ function jsonTypeOf(value: unknown): string {
346
+ if (value === null) return "null";
347
+ if (Array.isArray(value)) return "array";
348
+ return typeof value; // "string" | "number" | "boolean" | "object" | ...
349
+ }
350
+
322
351
  function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
323
352
  if (type === undefined) return true;
324
353
  switch (type) {
@@ -357,11 +386,15 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
357
386
  * → `cardinality_mismatch`
358
387
  *
359
388
  * 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).
389
+ * (vault#299) EXCEPT a `type_mismatch` on an `indexed: true` field, which is
390
+ * ALWAYS `strict: true` regardless of the field's own `strict` setting
391
+ * (vault#553 Decision A an indexed field's type is a query contract, not
392
+ * just guidance). The list itself is the SAME whether or not a field is
393
+ * strict the difference is only the per-warning `strict` flag, which the
394
+ * write path uses to decide block-vs-warn. Under `strict:false` (and
395
+ * `indexed:false`) this is byte-identical to the historical advisory
396
+ * behavior PLUS the new `required`/`cardinality` advisory reasons (which
397
+ * fire for any field declaring those, strict or not).
365
398
  *
366
399
  * Fields not declared by any ancestor's schema are ignored entirely.
367
400
  */
@@ -397,12 +430,20 @@ export function validateNote(
397
430
  if (absent) continue;
398
431
 
399
432
  if (spec.type && !valueMatchesType(value, spec.type)) {
433
+ // Decision A (vault#553): an INDEXED field's type is a query
434
+ // contract — a type-mismatched write poisons range-query ordering
435
+ // (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
436
+ // this field opted into `strict`. Force `strict: true` on THIS
437
+ // warning alone when the field is indexed; every other constraint
438
+ // (enum/required/cardinality) stays governed by `spec.strict` as
439
+ // before.
440
+ const indexedTypeStrict = spec.indexed === true;
400
441
  warnings.push({
401
442
  field: fieldName,
402
443
  schema: sourceTag,
403
444
  reason: "type_mismatch",
404
- message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
405
- ...strictFlag,
445
+ message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}'), got ${jsonTypeOf(value)}${indexedTypeStrict ? " — indexed fields reject type-mismatched writes (vault#553)" : ""}`,
446
+ ...(strictFlag.strict || indexedTypeStrict ? { strict: true } : {}),
406
447
  });
407
448
  }
408
449
 
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
- import { rebuildIndexes } from "./indexed-fields.js";
3
+ import { rebuildIndexes, listIndexedFields } from "./indexed-fields.js";
4
+ import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
4
5
  import { transaction } from "./txn.js";
5
6
 
6
- export const SCHEMA_VERSION = 23;
7
+ export const SCHEMA_VERSION = 24;
7
8
 
8
9
  export const SCHEMA_SQL = `
9
10
  -- Notes: the universal record.
@@ -502,6 +503,10 @@ export function initSchema(db: Database): void {
502
503
  // vault#298.
503
504
  migrateToV23(db);
504
505
 
506
+ // Migrate v23 → v24: coerce existing typed-index poison where lossless,
507
+ // leave the rest in place for `doctor` to surface. See vault#553.
508
+ migrateToV24(db);
509
+
505
510
  // Rebuild any generated columns + indexes declared in indexed_fields.
506
511
  // No-op for a fresh vault; idempotent on existing vaults.
507
512
  rebuildIndexes(db);
@@ -1187,6 +1192,146 @@ function migrateToV23(db: Database): void {
1187
1192
  }
1188
1193
  }
1189
1194
 
1195
+ /** Sentinel returned by {@link coerceIndexedValue} when no lossless conversion exists. */
1196
+ const NOT_COERCIBLE = Symbol("not-coercible");
1197
+
1198
+ /** Full JSON-number grammar — used to gate string→number coercion to values that round-trip exactly. */
1199
+ const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
1200
+
1201
+ /**
1202
+ * Decide the lossless coercion (if any) for a single poisoned indexed-field
1203
+ * value, given `jt` — the SQLite `json_type()` of its CURRENT value (the
1204
+ * SAME vocabulary `doctor`'s `findMixedTypeIndexedFieldNotes` compares
1205
+ * against: "text", "integer", "real", "true", "false", "array", "object")
1206
+ * — and `targetSqliteType`, the field's declared storage class ("INTEGER"
1207
+ * or "TEXT"). Returns the coerced value, or {@link NOT_COERCIBLE} when no
1208
+ * exact round-trip conversion exists — the caller leaves the value
1209
+ * untouched (never deletes or nulls note data).
1210
+ *
1211
+ * Coercible cases (vault#553 Decision D):
1212
+ * - INTEGER target, TEXT source: a clean numeric string ("5", "5.5",
1213
+ * "-3") → the JSON number, ONLY when `String(Number(str)) === str`
1214
+ * (exact round-trip — rejects "5.50", scientific-notation reformats,
1215
+ * and anything outside safe double precision that wouldn't survive the
1216
+ * trip). "true"/"false" (exact string match) → the JSON boolean.
1217
+ * - TEXT target, INTEGER/REAL/boolean source: a number or boolean → its
1218
+ * JSON string form via `String(value)` — always exact for finite JS
1219
+ * numbers and booleans within IEEE-754 double precision.
1220
+ * Never coercible: array/object/null values in either direction, or a TEXT
1221
+ * value that isn't a clean number/boolean string (e.g. "four", "5,000").
1222
+ */
1223
+ function coerceIndexedValue(
1224
+ value: unknown,
1225
+ jt: string,
1226
+ targetSqliteType: string,
1227
+ ): unknown | typeof NOT_COERCIBLE {
1228
+ if (targetSqliteType === "INTEGER") {
1229
+ if (jt !== "text" || typeof value !== "string") return NOT_COERCIBLE;
1230
+ if (value === "true") return true;
1231
+ if (value === "false") return false;
1232
+ if (JSON_NUMBER_RE.test(value)) {
1233
+ const n = Number(value);
1234
+ if (Number.isFinite(n) && String(n) === value) return n;
1235
+ }
1236
+ return NOT_COERCIBLE;
1237
+ }
1238
+ if (targetSqliteType === "TEXT") {
1239
+ if ((jt === "true" || jt === "false") && typeof value === "boolean") {
1240
+ return jt; // "true" / "false" — the json_type string IS the target text
1241
+ }
1242
+ if ((jt === "integer" || jt === "real") && typeof value === "number") {
1243
+ const s = String(value);
1244
+ if (Number(s) === value) return s;
1245
+ }
1246
+ return NOT_COERCIBLE;
1247
+ }
1248
+ return NOT_COERCIBLE;
1249
+ }
1250
+
1251
+ /**
1252
+ * Migrate v23 → v24: typed-index poison coercion (vault#553 Decision D).
1253
+ *
1254
+ * For every declared indexed field (`indexed_fields` — the SSOT from
1255
+ * `core/src/indexed-fields.ts`) and every note whose `metadata.<field>` JSON
1256
+ * type disagrees with the field's declared sqlite storage class — reusing
1257
+ * `doctor`'s `findMixedTypeIndexedFieldNotes` (`core/src/doctor.ts`) as the
1258
+ * DETECTOR, the exact same query that powers the `mixed_type_indexed_field`
1259
+ * finding, so migration and diagnostic can never disagree on what counts as
1260
+ * poisoned:
1261
+ *
1262
+ * - COERCE where {@link coerceIndexedValue} finds a lossless conversion —
1263
+ * rewrites `notes.metadata` in place (the generated `meta_<field>`
1264
+ * column re-derives via `json_extract` automatically; nothing else to
1265
+ * touch).
1266
+ * - LEAVE IN PLACE everything else. NEVER DELETE OR NULL NOTE DATA in a
1267
+ * startup migration — a non-coercible value (e.g. `"hello"` in an
1268
+ * integer field) stays exactly as written and remains visible via
1269
+ * `doctor`'s `mixed_type_indexed_field` finding for the operator to
1270
+ * clean up deliberately.
1271
+ *
1272
+ * Rewrites happen at the JS level — `SELECT metadata` → `JSON.parse` →
1273
+ * mutate the one field → `JSON.stringify` → single-row `UPDATE ... WHERE id
1274
+ * = ?` (two bound params) — rather than SQL `json_set`, so the migration
1275
+ * stays portable to a narrower json1 surface (Cloudflare DO SQLite; flagged
1276
+ * for the wire reviewer to confirm). Detection still uses
1277
+ * `json_extract`/`json_type`/`json_valid`, the same functions the indexed
1278
+ * generated columns and `doctor` already depend on everywhere in this
1279
+ * codebase — not a new cross-runtime risk. One row at a time also means this
1280
+ * never approaches SQLite's bound-parameter ceiling regardless of how many
1281
+ * notes are poisoned (no batched `WHERE id IN (...)`).
1282
+ *
1283
+ * Idempotent: every step is naturally re-runnable (a coerced value now
1284
+ * agrees with the declared type and is never re-detected as a mismatch; a
1285
+ * left-in-place value re-detects identically and is re-skipped identically).
1286
+ * Safe on a vault with zero indexed fields (the field-list guard short-
1287
+ * circuits before any table scan) and safe to run on every boot, matching
1288
+ * the unconditional-migration-function idiom the rest of this file uses.
1289
+ */
1290
+ function migrateToV24(db: Database): void {
1291
+ if (!hasTable(db, "notes") || !hasTable(db, "indexed_fields")) return;
1292
+ const fields = listIndexedFields(db);
1293
+ if (fields.length === 0) return;
1294
+
1295
+ let coerced = 0;
1296
+ let left = 0;
1297
+
1298
+ transaction(db, () => {
1299
+ const readStmt = db.prepare("SELECT metadata FROM notes WHERE id = ?");
1300
+ const updateStmt = db.prepare("UPDATE notes SET metadata = ? WHERE id = ?");
1301
+
1302
+ for (const f of fields) {
1303
+ const mismatches = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
1304
+ for (const { id, jt } of mismatches) {
1305
+ const row = readStmt.get(id) as { metadata: string | null } | null;
1306
+ if (!row?.metadata) continue;
1307
+ let meta: unknown;
1308
+ try {
1309
+ meta = JSON.parse(row.metadata);
1310
+ } catch {
1311
+ continue; // shouldn't happen — findMixedTypeIndexedFieldNotes already filters to json_valid rows
1312
+ }
1313
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
1314
+ const obj = meta as Record<string, unknown>;
1315
+ if (!(f.field in obj)) continue;
1316
+ const coercedValue = coerceIndexedValue(obj[f.field], jt, f.sqliteType);
1317
+ if (coercedValue === NOT_COERCIBLE) {
1318
+ left++;
1319
+ continue;
1320
+ }
1321
+ obj[f.field] = coercedValue;
1322
+ updateStmt.run(JSON.stringify(obj), id);
1323
+ coerced++;
1324
+ }
1325
+ }
1326
+ });
1327
+
1328
+ if (coerced > 0 || left > 0) {
1329
+ console.log(
1330
+ `[vault] migrated to schema v24 (vault#553): typed-index poison scan — coerced ${coerced} value(s) to their declared indexed type; left ${left} non-coercible value(s) in place (see \`doctor\`'s mixed_type_indexed_field finding for cleanup).`,
1331
+ );
1332
+ }
1333
+ }
1334
+
1190
1335
  function hasTable(db: Database, name: string): boolean {
1191
1336
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
1192
1337
  return !!row;
@@ -468,9 +468,9 @@ update-tag {
468
468
  tag: "meeting",
469
469
  description: "A meeting with notes",
470
470
  fields: {
471
- held_on: { type: "string", indexed: true }, // queryable with operators
472
- status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
473
- rating: { type: "integer" }
471
+ held_on: { type: "string", indexed: true }, // queryable with operators
472
+ status: { type: "string", enum: ["scheduled", "done"], default: "scheduled" }, // explicit default declare one to auto-fill
473
+ rating: { type: "integer" } // no default — stays unset until written
474
474
  }
475
475
  }
476
476
  \`\`\`
@@ -490,16 +490,23 @@ A few behaviors worth knowing before you write at scale:
490
490
  conflict error (re-read, reconcile, retry). For bulk/scripted writes where
491
491
  concurrency is known-safe, pass \`force: true\` to waive the *requirement to
492
492
  supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
493
- - **A schema field's default is filled in on write, so it shows up even when you
494
- didn't set it.** When a note gets a tag whose schema declares a field, the
495
- missing field is back-filled: an \`enum\` field its **first listed value**, an
496
- \`integer\` \`0\`, a \`boolean\` \`false\`, a plain string \`""\`. So a
497
- \`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
498
- is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
499
- default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
500
- - **Validation is advisory, never blocking.** A type/enum mismatch comes back as
501
- a \`validation_status\` warning on the write response the write still lands.
502
- Read those warnings and self-correct on the next turn.
493
+ - **A schema field only back-fills when you declare an explicit \`default\`.**
494
+ When a note gets a tag whose schema declares a field with a \`default\`, an
495
+ unset field is filled with THAT value. A field with no \`default\` stays
496
+ genuinely absent \`query-notes { metadata: { rating: { exists: false } } }\`
497
+ reliably finds notes that never set \`rating\`. Declare \`default\` on a field
498
+ only when "unset" and "explicitly set to X" should read the same; leave it
499
+ off when you need to tell them apart.
500
+ - **Validation is advisory, never blocking EXCEPT an \`indexed: true\`
501
+ field's type.** A type/enum mismatch on a non-indexed field (or a
502
+ non-\`strict\` constraint on any field) comes back as a \`validation_status\`
503
+ warning and the write still lands — read those warnings and self-correct on
504
+ the next turn. An \`indexed: true\` field's TYPE is always enforced, though:
505
+ writing a string into an indexed \`integer\` field is REJECTED with a
506
+ \`schema_validation\` error, because a bad-typed value would otherwise
507
+ silently poison range queries (\`gt\`/\`gte\`/\`lt\`/\`lte\`) on that field. Mark a
508
+ field \`strict: true\` to enforce its OTHER constraints
509
+ (enum/required/cardinality) the same hard way.
503
510
 
504
511
  (Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
505
512
 
package/core/src/store.ts CHANGED
@@ -741,6 +741,24 @@ export class BunSqliteStore implements Store {
741
741
  // Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
742
742
  indexedFieldOps.validateFieldName(fieldName);
743
743
  }
744
+ // Default-conformance pre-validate (vault#553 Decision B) — mirrors the
745
+ // indexed-type/name checks above: fail BEFORE any persistence so a bad
746
+ // `default` never gets written. Runs over EVERY field in the full
747
+ // (already-merged) `nextFields` map, not just indexed ones — a default
748
+ // that doesn't match its own field's type/enum is a tag-schema error
749
+ // regardless of queryability. This is the REST parity path: MCP's
750
+ // `update-tag` tool ALSO calls `collectTagFieldViolations` before
751
+ // reaching here (bundling every violation into one `TagFieldConflictError`
752
+ // — see that function's doc comment), so a conforming MCP call never
753
+ // trips this; a non-conforming one throws there first. REST has no such
754
+ // pre-check, so this is REST's only gate — same asymmetry as the
755
+ // indexed-type/name checks it sits beside.
756
+ for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
757
+ const violation = tagSchemaOps.validateFieldDefault(fieldName, spec);
758
+ if (violation) {
759
+ throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, violation.message);
760
+ }
761
+ }
744
762
  }
745
763
 
746
764
  // Persist the record + reconcile the indexed-field lifecycle atomically.
@@ -44,6 +44,25 @@ export interface TagFieldSchema {
44
44
  // "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
45
45
  // Distinct from relationship cardinality. vault#299.
46
46
  cardinality?: "one" | "many";
47
+ /**
48
+ * Explicit backfill value (vault#553 Decision B). When a note gains this
49
+ * tag and doesn't set the field, `applySchemaDefaults` (core/src/mcp.ts)
50
+ * writes THIS value onto the note. Undeclared (the default) means the
51
+ * field stays ABSENT on notes that don't set it — no more implicit
52
+ * first-enum-value / type-zero-value backfill. This is what makes
53
+ * `exists:false` trustworthy: "never set" and "explicitly set to the
54
+ * default" are now distinguishable states.
55
+ *
56
+ * Must conform to this field's own `type` (and `enum`, when declared) —
57
+ * `upsertTagRecord` (core/src/store.ts, the single chokepoint both REST
58
+ * and MCP funnel through) validates the default BEFORE persisting via
59
+ * {@link validateFieldDefault}; a non-conforming default is a tag-schema
60
+ * error (`invalid_field_default` / `TagFieldViolation` reason
61
+ * `"invalid_default"`), not a silent typo. `undefined` is JSON-serialized
62
+ * away by `JSON.stringify` — an omitted `default` key round-trips as "not
63
+ * declared", never as a stored `null`.
64
+ */
65
+ default?: unknown;
47
66
  }
48
67
 
49
68
  /**
@@ -147,6 +166,86 @@ export function getTagRecord(db: Database, tag: string): TagRecord | null {
147
166
  * for a tag-scoped caller (`scrubParentCycleError` in src/tag-scope.ts),
148
167
  * same posture as `TagFieldConflictError`.
149
168
  */
169
+ /**
170
+ * Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint — REST
171
+ * `PUT /api/tags/:name` and MCP `update-tag` both funnel through it) when a
172
+ * field's declared `default` (vault#553 Decision B) doesn't conform to that
173
+ * SAME field's own `type` (or `enum`, when declared). A bad default is a
174
+ * tag-schema authoring error, not silently-accepted junk that would poison
175
+ * every note gaining the tag — fail closed, same posture as
176
+ * `IndexedFieldError` and `ParentCycleError`. `error_type` is stamped
177
+ * directly (own-field validation leaf, one caller-facing shape) so the
178
+ * generic MCP domain-error mapping (`src/mcp-http.ts`) and REST's dedicated
179
+ * catch branch both surface it without a bespoke class-specific mapping.
180
+ */
181
+ export class InvalidFieldDefaultError extends Error {
182
+ code = "INVALID_FIELD_DEFAULT" as const;
183
+ error_type = "invalid_field_default" as const;
184
+ field: string;
185
+
186
+ constructor(field: string, message: string) {
187
+ super(message);
188
+ this.name = "InvalidFieldDefaultError";
189
+ this.field = field;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Validate that a field's declared `default` (vault#553 Decision B) conforms
195
+ * to its own `type` and (when declared) `enum`. Returns `null` when the
196
+ * field has no `default` (the common case) or the default is valid;
197
+ * otherwise a {@link TagFieldViolation} with `reason: "invalid_default"`.
198
+ * Pure — never throws; the two callers (the store chokepoint's fail-fast
199
+ * pre-validate, and {@link collectTagFieldViolations}'s bundled MCP report)
200
+ * each decide how to surface it.
201
+ *
202
+ * Deliberately independent of `schema-defaults.ts`'s `valueMatchesType` (this
203
+ * module doesn't import that one, and vice versa — no cycle either way) so
204
+ * the two modules' type vocabularies can drift without cross-coupling; the
205
+ * rules are kept in lockstep by hand (string/number/integer/boolean/array/
206
+ * object, the SAME six as `TagFieldSchema.type`'s documented vocabulary).
207
+ */
208
+ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFieldViolation | null {
209
+ if (spec.default === undefined) return null;
210
+ const value = spec.default;
211
+ const typeOk = defaultMatchesType(value, spec.type);
212
+ if (!typeOk) {
213
+ return {
214
+ field,
215
+ reason: "invalid_default",
216
+ message: `field "${field}" declares default ${JSON.stringify(value)}, which does not match its own type "${spec.type}"`,
217
+ };
218
+ }
219
+ if (spec.enum && spec.enum.length > 0 && typeof value === "string" && !spec.enum.includes(value)) {
220
+ return {
221
+ field,
222
+ reason: "invalid_default",
223
+ message: `field "${field}" declares default "${value}", which is not one of its own enum values [${spec.enum.join(", ")}]`,
224
+ };
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
230
+ function defaultMatchesType(value: unknown, type: string): boolean {
231
+ switch (type) {
232
+ case "string":
233
+ return typeof value === "string";
234
+ case "number":
235
+ return typeof value === "number" && Number.isFinite(value);
236
+ case "integer":
237
+ return typeof value === "number" && Number.isInteger(value);
238
+ case "boolean":
239
+ return typeof value === "boolean";
240
+ case "array":
241
+ return Array.isArray(value);
242
+ case "object":
243
+ return !!value && typeof value === "object" && !Array.isArray(value);
244
+ default:
245
+ return true;
246
+ }
247
+ }
248
+
150
249
  export class ParentCycleError extends Error {
151
250
  code = "PARENT_CYCLE" as const;
152
251
  error_type = "parent_cycle" as const;
@@ -403,7 +502,12 @@ function jsonOrNull(value: unknown): string | null {
403
502
  */
404
503
  export interface TagFieldViolation {
405
504
  field: string;
406
- reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
505
+ reason:
506
+ | "type_conflict"
507
+ | "indexed_flag_conflict"
508
+ | "unsupported_indexed_type"
509
+ | "invalid_field_name"
510
+ | "invalid_default";
407
511
  message: string;
408
512
  /**
409
513
  * The conflicting declarer tag — present on the cross-tag reasons
@@ -522,12 +626,17 @@ export function collectCrossTagFieldViolations(
522
626
  /**
523
627
  * Full field-violation collection: {@link collectCrossTagFieldViolations}
524
628
  * PLUS the own-field checks (unsupported type for indexing, invalid
525
- * identifier). Used by the MCP `update-tag` tool, which unlike REST — had
526
- * no prior single-violation status-code contract to preserve for those two
527
- * checks (its old inline loop threw an unstructured `Error` for them, same
528
- * as everything else pre-#554); collecting everything here is a strict
629
+ * identifier, and vault#553 Decision Ba non-conforming `default`).
630
+ * Used by the MCP `update-tag` tool, which unlike REST — had no prior
631
+ * single-violation status-code contract to preserve for those checks (its
632
+ * old inline loop threw an unstructured `Error` for them, same as
633
+ * everything else pre-#554); collecting everything here is a strict
529
634
  * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
530
- * why REST calls that narrower function directly instead of this one.
635
+ * why REST calls that narrower function directly instead of this one — REST
636
+ * gets the SAME `invalid_default` coverage via `store.upsertTagRecord`'s
637
+ * fail-fast pre-validate (mirrors its indexed-type/name checks), just as a
638
+ * single-violation `InvalidFieldDefaultError` → 400 rather than a bundled
639
+ * `TagFieldConflictError` → 422.
531
640
  */
532
641
  export function collectTagFieldViolations(
533
642
  db: Database,
@@ -537,6 +646,12 @@ export function collectTagFieldViolations(
537
646
  const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
538
647
 
539
648
  for (const [fieldName, spec] of Object.entries(incomingFields)) {
649
+ // Own-field default-conformance check (vault#553 Decision B) — applies
650
+ // to EVERY field (not just indexed ones); a bad default is a tag-schema
651
+ // error regardless of whether the field is queryable.
652
+ const defaultViolation = validateFieldDefault(fieldName, spec);
653
+ if (defaultViolation) violations.push(defaultViolation);
654
+
540
655
  if (spec.indexed === true) {
541
656
  const mapped = mapFieldType(spec.type);
542
657
  if (!mapped) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.0-rc.5",
3
+ "version": "0.7.0-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -166,8 +166,11 @@ describe("seedOnboardingNotes — default packs (welcome + getting-started)", ()
166
166
  expect(gs!.content).toContain("## Write gotchas");
167
167
  expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
168
168
  expect(gs!.content).toContain("force: true");
169
- // schema-default back-fill: enum→first value, integer→0.
170
- expect(gs!.content).toContain("first listed value");
169
+ // schema-default back-fill is explicit-`default`-only (vault#553 Decision B).
170
+ expect(gs!.content).toContain("explicit `default`");
171
+ expect(gs!.content).toContain("exists: false");
172
+ // indexed⇒strict type enforcement (vault#553 Decision A).
173
+ expect(gs!.content).toContain("field's TYPE is always enforced");
171
174
  });
172
175
 
173
176
  test("A3 (reshaped): the welcome ring resolves to real link edges; Getting Started has no dangling Surface Starter link", async () => {
package/src/routes.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  contentRangeRequiresContent,
25
25
  type ContentRange,
26
26
  } from "../core/src/content-range.ts";
27
- import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
27
+ import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
28
28
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
29
29
  import { logStrictBypass } from "./scopes.ts";
30
30
  import * as linkOps from "../core/src/links.ts";
@@ -48,7 +48,7 @@ import {
48
48
  tagScopeForbidden,
49
49
  tagsWithinScope,
50
50
  } from "./tag-scope.ts";
51
- import { ParentCycleError } from "../core/src/tag-schemas.ts";
51
+ import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
52
52
  import { findTokensReferencingTag } from "./token-store.ts";
53
53
 
54
54
  /**
@@ -2668,6 +2668,18 @@ export async function handleTags(
2668
2668
  400,
2669
2669
  );
2670
2670
  }
2671
+ if (err instanceof InvalidFieldDefaultError) {
2672
+ // vault#553 Decision B — a declared `default` doesn't conform to its
2673
+ // own field's type/enum. Own-field error (no cross-tag data), so no
2674
+ // scrub needed. Mirrors IndexedFieldError's 400 shape/posture — see
2675
+ // store.upsertTagRecord's pre-validate comment for why REST hits
2676
+ // this (fail-fast, single violation) while MCP's update-tag usually
2677
+ // reports it bundled via `TagFieldConflictError` instead.
2678
+ return json(
2679
+ { error: err.message, error_type: "invalid_field_default", field: err.field },
2680
+ 400,
2681
+ );
2682
+ }
2671
2683
  if (err instanceof ParentCycleError) {
2672
2684
  // vault#552: parent_names would close a cycle. Nothing was
2673
2685
  // persisted. Scope-scrub the cycle path for a tag-scoped caller —
@@ -3735,56 +3747,10 @@ export async function handleStorage(
3735
3747
  return json({ error: "Not found", error_type: "not_found" }, 404);
3736
3748
  }
3737
3749
 
3738
- // ---------------------------------------------------------------------------
3739
- // Tag schema defaults same logic as core/src/mcp.ts applySchemaDefaults
3740
- // ---------------------------------------------------------------------------
3741
-
3742
- // Returns the IDs of notes whose metadata was actually default-filled, so
3743
- // the caller can re-read ONLY the mutated notes (and skip the re-read when
3744
- // nothing changed). Mirrors the core/src/mcp.ts contract.
3745
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
3746
- const schemas = tagSchemaOps.getTagSchemaMap(db);
3747
- if (Object.keys(schemas).length === 0) return [];
3748
-
3749
- const defaults: Record<string, unknown> = {};
3750
- for (const tag of tags) {
3751
- const schema = schemas[tag];
3752
- if (!schema?.fields) continue;
3753
- for (const [field, fieldSchema] of Object.entries(schema.fields)) {
3754
- if (!(field in defaults)) {
3755
- defaults[field] = defaultForField(fieldSchema);
3756
- }
3757
- }
3758
- }
3759
- if (Object.keys(defaults).length === 0) return [];
3760
-
3761
- const mutated: string[] = [];
3762
- for (const noteId of noteIds) {
3763
- const note = await store.getNote(noteId);
3764
- if (!note) continue;
3765
- const existing = (note.metadata as Record<string, unknown>) ?? {};
3766
- const missing: Record<string, unknown> = {};
3767
- for (const [field, value] of Object.entries(defaults)) {
3768
- if (!(field in existing)) missing[field] = value;
3769
- }
3770
- if (Object.keys(missing).length === 0) continue;
3771
- await store.updateNote(noteId, {
3772
- metadata: { ...existing, ...missing },
3773
- skipUpdatedAt: true,
3774
- });
3775
- mutated.push(noteId);
3776
- }
3777
- return mutated;
3778
- }
3779
-
3780
- function defaultForField(field: { type: string; enum?: string[] }): unknown {
3781
- if (field.enum && field.enum.length > 0) return field.enum[0];
3782
- switch (field.type) {
3783
- case "boolean": return false;
3784
- case "integer": return 0;
3785
- default: return "";
3786
- }
3787
- }
3750
+ // applySchemaDefaults lives in core/src/mcp.ts (vault#553) — REST used to
3751
+ // carry a byte-identical duplicate; importing it means REST and MCP can
3752
+ // never drift on this behavior again, and the cloud runtime (which imports
3753
+ // core directly) inherits it without any handler-side code.
3788
3754
 
3789
3755
  function removeWikilinkBrackets(content: string, targetPath: string): string {
3790
3756
  const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");