@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.8

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
@@ -15,6 +15,7 @@ import {
15
15
  } from "./query-warnings.js";
16
16
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
17
17
  import * as linkOps from "./links.js";
18
+ import { resolveOrQueueLink, resolveStructuredLinkNote } from "./wikilinks.js";
18
19
  import * as tagSchemaOps from "./tag-schemas.js";
19
20
  import type { TagFieldSchema } from "./tag-schemas.js";
20
21
  import {
@@ -263,6 +264,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
263
264
 
264
265
  Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
265
266
 
267
+ Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
268
+
266
269
  Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
267
270
 
268
271
  Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
@@ -458,6 +461,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
458
461
  // `expand`).
459
462
  if (contentRange && !includeContent) throw contentRangeRequiresContent();
460
463
  let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
464
+ // --- Attach validation_status (vault#555 fix 3) ---
465
+ // Mirrors create/update-note's `attachValidationStatus` — additive,
466
+ // present only when at least one tag on the note declares
467
+ // `fields`. Before this, `validation_status` (including an
468
+ // `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
469
+ // on the one-time create/update WRITE response; a caller reading
470
+ // the note back via `query-notes` (the natural way to re-check a
471
+ // value after write) saw nothing at all — contradicting the
472
+ // documented "advisory violations surface as warnings" for every
473
+ // read after the initial write.
474
+ {
475
+ const status = store.validateNoteAgainstSchemas({
476
+ path: note.path,
477
+ tags: note.tags,
478
+ metadata: note.metadata as Record<string, unknown> | undefined,
479
+ });
480
+ if (status) result.validation_status = status;
481
+ }
461
482
  if (expandCtx && includeContent && typeof result.content === "string") {
462
483
  // Mark the top-level note as already expanded so it can't recursively inline itself.
463
484
  expandCtx.expanded.add(note.id);
@@ -738,6 +759,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
738
759
  for (const n of output) applyContentRange(n, contentRange);
739
760
  }
740
761
 
762
+ // --- Attach validation_status (vault#555 fix 3) ---
763
+ // Same reasoning as the single-note path above — additive, present
764
+ // only when at least one tag on the note declares `fields`. Runs
765
+ // BEFORE metadata filtering so it sees the note's full metadata
766
+ // regardless of `include_metadata`; the `validation_status` key
767
+ // itself survives `filterMetadata` (which only touches `metadata`).
768
+ {
769
+ const statusById = new Map(
770
+ results.map((n) => [
771
+ n.id,
772
+ store.validateNoteAgainstSchemas({
773
+ path: n.path,
774
+ tags: n.tags,
775
+ metadata: n.metadata as Record<string, unknown> | undefined,
776
+ }),
777
+ ]),
778
+ );
779
+ for (const n of output as any[]) {
780
+ const status = statusById.get(n.id);
781
+ if (status) n.validation_status = status;
782
+ }
783
+ }
784
+
741
785
  // --- Apply metadata filtering ---
742
786
  if (includeMetadata !== undefined && includeMetadata !== true) {
743
787
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
@@ -826,7 +870,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
826
870
  },
827
871
  required: ["target", "relationship"],
828
872
  },
829
- description: "Links to create from this note",
873
+ description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
830
874
  },
831
875
  created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
832
876
  // Batch
@@ -858,6 +902,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
858
902
  }
859
903
 
860
904
  const created: Note[] = [];
905
+ // Structured `links` are resolved in a SECOND pass, after every
906
+ // note in this batch has been created (vault#555) — resolving
907
+ // inline (the old behavior) meant a link from item 0 to item 1's
908
+ // path silently dropped, since item 1 didn't exist yet at the
909
+ // moment item 0's links were processed. Deferring makes
910
+ // within-batch forward-refs resolve exactly like a same-content
911
+ // [[wikilink]] already does. `pendingLinks` carries the SOURCE
912
+ // note id (known immediately — createNote assigns it synchronously)
913
+ // alongside each item's raw `links` array.
914
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
915
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
916
+ // Populated in the second pass; folded into each note's response
917
+ // below, same pattern as `validation_status`.
918
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
861
919
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
862
920
  // failure rolls back every prior insert — see #236. This guards
863
921
  // anything thrown from store.createNote / createLink (path
@@ -892,18 +950,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
892
950
  via: writeVia,
893
951
  });
894
952
 
895
- // Create explicit links (not wikilinks — those are automatic)
896
953
  if (item.links) {
897
- for (const link of item.links as { target: string; relationship: string }[]) {
898
- const target = resolveNote(db, link.target);
899
- if (target) {
900
- await store.createLink(note.id, target.id, link.relationship);
901
- }
902
- }
954
+ pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
903
955
  }
904
956
 
905
957
  created.push(noteOps.getNote(db, note.id) ?? note);
906
958
  }
959
+
960
+ // --- Resolve structured links (vault#555) ---
961
+ // Same semantics as [[wikilinks]]: ID or path/title match, tried
962
+ // now that every sibling note in this batch exists. A target that
963
+ // STILL doesn't resolve (typo, or a note that arrives in a later
964
+ // call) is queued for lazy resolution — it backfills automatically
965
+ // the moment a matching note is created — and surfaces an
966
+ // `unresolved_link` warning naming the target. Never silent.
967
+ for (const { sourceId, links } of pendingLinks) {
968
+ for (const link of links) {
969
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
970
+ if (targetId) {
971
+ await store.createLink(sourceId, targetId, link.relationship);
972
+ } else {
973
+ const list = linkWarningsByNote.get(sourceId) ?? [];
974
+ list.push({
975
+ code: "unresolved_link",
976
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
977
+ target: link.target,
978
+ relationship: link.relationship,
979
+ });
980
+ linkWarningsByNote.set(sourceId, list);
981
+ }
982
+ }
983
+ }
907
984
  };
908
985
  await (batched ? transactionAsync(db, runBatch) : runBatch());
909
986
 
@@ -934,8 +1011,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
934
1011
  })();
935
1012
 
936
1013
  // Attach `validation_status` from any tag's `fields` declaration that
937
- // applies to this note, against the post-defaults state.
938
- const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1014
+ // applies to this note, against the post-defaults state. Fold in any
1015
+ // `unresolved_link` warnings collected above (vault#555) additive,
1016
+ // present only when this note's `links` had a target that didn't
1017
+ // resolve.
1018
+ const final = refreshed.map((n) => {
1019
+ const validated = attachValidationStatus(store, db, n);
1020
+ const warnings = linkWarningsByNote.get(n.id);
1021
+ return warnings && warnings.length > 0
1022
+ ? ({ ...validated, warnings } as Note & { warnings: QueryWarning[] })
1023
+ : validated;
1024
+ });
939
1025
  return batch ? final : final[0];
940
1026
  },
941
1027
  },
@@ -1028,7 +1114,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1028
1114
  },
1029
1115
  },
1030
1116
  },
1031
- description: "Links to add/remove",
1117
+ description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
1032
1118
  },
1033
1119
  include_content: {
1034
1120
  type: "boolean",
@@ -1122,6 +1208,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1122
1208
  // key this so the create-on-missing branch, which assigns the id
1123
1209
  // late, can register correctly.
1124
1210
  const echoLinkIds = new Set<string>();
1211
+ // Structured `links.add` entries, deferred to a second pass AFTER
1212
+ // every item in this batch has been created/updated (vault#555) —
1213
+ // resolving inline would silently drop a link from item 0 to a note
1214
+ // item 1 (later in the same batch) creates via `if_missing:
1215
+ // "create"`. Same fix as create-note's `pendingLinks`.
1216
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
1217
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
1218
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
1125
1219
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
1126
1220
  // failure (precondition error, content_edit miss, ConflictError, …)
1127
1221
  // rolls back every prior mutation in the batch — see #236.
@@ -1212,13 +1306,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1212
1306
  });
1213
1307
  const created = await store.createNote(content, createOpts);
1214
1308
  await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
1215
- // Apply links.add if the caller declared any.
1309
+ // Defer links.add resolution to the second pass (vault#555)
1310
+ // — same reasoning as create-note.
1216
1311
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1217
1312
  if (linksAdd) {
1218
- for (const link of linksAdd) {
1219
- const target = resolveNote(db, link.target);
1220
- if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
1221
- }
1313
+ pendingLinks.push({ sourceId: created.id, links: linksAdd });
1222
1314
  }
1223
1315
  const fresh = noteOps.getNote(db, created.id) ?? created;
1224
1316
  updated.push(fresh);
@@ -1326,7 +1418,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1326
1418
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1327
1419
  if (linksRemove) {
1328
1420
  for (const link of linksRemove) {
1329
- const target = resolveNote(db, link.target);
1421
+ const target = resolveStructuredLinkNote(db, link.target);
1330
1422
  if (!target) continue;
1331
1423
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1332
1424
  if (link.relationship === "wikilink" && target.path) {
@@ -1400,8 +1492,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1400
1492
  enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1401
1493
  }
1402
1494
 
1495
+ // vault#555 fix 2 — tag and link mutations must bump `updated_at`
1496
+ // too, not just core-field (content/path/metadata) changes. Before
1497
+ // this, a tags-only or links-only call with `force: true` (no
1498
+ // `if_updated_at`) left `updates` completely empty — the branch
1499
+ // below skipped `store.updateNote` ENTIRELY, so `updated_at` never
1500
+ // moved even though tags/note_tags or links genuinely changed.
1501
+ // (A tags-only call that happened to pass `if_updated_at` already
1502
+ // worked by accident — it populated `updates.if_updated_at` — which
1503
+ // is why this was easy to miss in ad hoc testing.) This breaks
1504
+ // cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
1505
+ // sync filter: the mutation is real but invisible to a
1506
+ // since-last-check loop.
1507
+ const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
1508
+ || ((item.tags as any)?.remove?.length ?? 0) > 0;
1509
+ const hasLinkMutation = (item.links as any)?.add !== undefined
1510
+ || (item.links as any)?.remove !== undefined;
1511
+
1403
1512
  let result: Note;
1404
- if (Object.keys(updates).length > 0) {
1513
+ if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
1405
1514
  // Write-attribution (vault#298): stamp the most-recent-write
1406
1515
  // columns on the same UPDATE that bumps `updated_at`. Only set when
1407
1516
  // there's a real change to write (the empty-updates branch below
@@ -1412,7 +1521,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1412
1521
  // store.updateNote routes through noteOps.updateNote, which runs
1413
1522
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
1414
1523
  // throws ConflictError on mismatch. No mutations have happened
1415
- // yet, so a throw here leaves the note untouched.
1524
+ // yet, so a throw here leaves the note untouched. `updates` may
1525
+ // carry no core fields at all (a pure tag/link mutation) — that's
1526
+ // fine: `noteOps.updateNote` unconditionally SETs
1527
+ // `updated_at`/`last_updated_by`/`last_updated_via` whenever
1528
+ // `skipUpdatedAt` isn't set, so this still issues a real UPDATE
1529
+ // (not the true no-op/precondition-only branch, which only fires
1530
+ // when the caller passes ZERO fields AND no mutation is pending).
1416
1531
  result = await store.updateNote(note.id, updates);
1417
1532
  } else {
1418
1533
  result = note;
@@ -1433,15 +1548,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1433
1548
  await store.untagNote(note.id, tagsOp.remove);
1434
1549
  }
1435
1550
 
1436
- // --- Add links ---
1551
+ // --- Add links (deferred to the second pass — vault#555) ---
1437
1552
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1438
1553
  if (linksAdd) {
1439
- for (const link of linksAdd) {
1440
- const target = resolveNote(db, link.target);
1441
- if (target) {
1442
- await store.createLink(note.id, target.id, link.relationship, link.metadata);
1443
- }
1444
- }
1554
+ pendingLinks.push({ sourceId: note.id, links: linksAdd });
1445
1555
  }
1446
1556
 
1447
1557
  // Echo links if this update mutated them (`links.add`/`links.remove`)
@@ -1454,6 +1564,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1454
1564
  // Re-read for final state
1455
1565
  updated.push(noteOps.getNote(db, note.id) ?? result);
1456
1566
  }
1567
+
1568
+ // --- Resolve structured `links.add` (vault#555) ---
1569
+ // Deferred until every item in this batch has been created/
1570
+ // updated — same [[wikilink]] semantics (ID or path/title match),
1571
+ // now tried against the FULL post-batch note set so a forward-ref
1572
+ // to a sibling item resolves. A target that still doesn't resolve
1573
+ // is queued for lazy resolution (backfills when a matching note
1574
+ // is created later) and surfaces an `unresolved_link` warning —
1575
+ // never silently dropped.
1576
+ for (const { sourceId, links } of pendingLinks) {
1577
+ for (const link of links) {
1578
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1579
+ if (targetId) {
1580
+ await store.createLink(sourceId, targetId, link.relationship, link.metadata);
1581
+ } else {
1582
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1583
+ list.push({
1584
+ code: "unresolved_link",
1585
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1586
+ target: link.target,
1587
+ relationship: link.relationship,
1588
+ });
1589
+ linkWarningsByNote.set(sourceId, list);
1590
+ }
1591
+ }
1592
+ }
1457
1593
  };
1458
1594
  await (batched ? transactionAsync(db, runBatch) : runBatch());
1459
1595
 
@@ -1475,9 +1611,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1475
1611
  // when triggered — mirrors the GET / query-notes shape exactly via
1476
1612
  // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1477
1613
  const echoLinks = echoLinkIds.has(n.id);
1614
+ // `unresolved_link` warnings (vault#555) — additive, present only
1615
+ // when this note's `links.add` had a target that didn't resolve.
1616
+ const warnings = linkWarningsByNote.get(n.id);
1478
1617
  if (includeContent) {
1479
1618
  const full: any = { ...validated, created };
1480
1619
  if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1620
+ if (warnings && warnings.length > 0) full.warnings = warnings;
1481
1621
  return full as Note & { created: boolean };
1482
1622
  }
1483
1623
  const lean: any = noteOps.toNoteIndex(validated);
@@ -1487,6 +1627,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1487
1627
  // Carry the link echo across the lean conversion — `toNoteIndex`
1488
1628
  // drops unknown fields.
1489
1629
  if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
1630
+ if (warnings && warnings.length > 0) lean.warnings = warnings;
1490
1631
  return lean;
1491
1632
  });
1492
1633
  return batch ? final : final[0];
@@ -1615,7 +1756,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1615
1756
  additionalProperties: {
1616
1757
  type: "object",
1617
1758
  properties: {
1618
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1759
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1619
1760
  description: { type: "string" },
1620
1761
  enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
1621
1762
  default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
package/core/src/notes.ts CHANGED
@@ -1926,11 +1926,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1926
1926
  const candidates = db
1927
1927
  .prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
1928
1928
  .all(...params) as { id: string; content: string }[];
1929
- const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
1929
+ // vault#555 fix 2 this content rewrite is a real persisted-state
1930
+ // change (the note's `#oldtag` references literally became
1931
+ // `#newtag`) and must bump `updated_at` like any other content
1932
+ // write, or a cursor/sync-poll loop never sees it. Shares the one
1933
+ // `now` timestamp for the whole cascade (same convention as the
1934
+ // tag-row rename pass above).
1935
+ const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
1930
1936
  for (const row of candidates) {
1931
1937
  const next = rewriteNoteBody(row.content, renames);
1932
1938
  if (next === row.content) continue;
1933
- updateStmt.run(next, row.id);
1939
+ updateStmt.run(next, now, row.id);
1934
1940
  notesRewritten++;
1935
1941
  }
1936
1942
  }
@@ -1945,11 +1951,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1945
1951
  const candidates = db
1946
1952
  .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
1947
1953
  .all(...params) as { id: string; path: string }[];
1948
- const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
1954
+ // vault#555 fix 2 same reasoning as the content rewrite above: a
1955
+ // path rewrite is a real persisted-state change.
1956
+ const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
1949
1957
  for (const row of candidates) {
1950
1958
  const next = rewriteTagConfigPath(row.path, renames);
1951
1959
  if (next === row.path) continue;
1952
- updateStmt.run(next, row.id);
1960
+ updateStmt.run(next, now, row.id);
1953
1961
  pathsRenamed++;
1954
1962
  }
1955
1963
  }
package/core/src/store.ts CHANGED
@@ -741,22 +741,29 @@ 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.
744
+ // Default-conformance + type-vocabulary pre-validate (vault#553
745
+ // Decision B; vault#555 fix 4/5) mirrors the indexed-type/name
746
+ // checks above: fail BEFORE any persistence so a bad `default` or an
747
+ // unrecognized `type` never gets written. Runs over EVERY field in the
748
+ // full (already-merged) `nextFields` map, not just indexed ones both
749
+ // are tag-schema errors regardless of queryability. This is a
750
+ // DEFENSE-IN-DEPTH backstop, not the primary user-facing gate: both
751
+ // REST's `PUT /api/tags/:name` (`collectCrossTagFieldViolations` +
752
+ // `collectOwnFieldDefaultAndTypeViolations`, bundled 422) and MCP's
753
+ // `update-tag` (`collectTagFieldViolations`, same bundle) now
754
+ // pre-validate every field and report ALL violations together BEFORE
755
+ // ever reaching this chokepoint — a conforming call never trips this
756
+ // fail-fast loop. It stays here so any OTHER caller of
757
+ // `store.upsertTagRecord` (imports, migrations, scripts) still fails
758
+ // closed rather than persisting a lying schema.
756
759
  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
+ const typeViolation = tagSchemaOps.validateFieldType(fieldName, spec);
761
+ if (typeViolation) {
762
+ throw new tagSchemaOps.InvalidFieldTypeError(fieldName, spec.type);
763
+ }
764
+ const defaultViolation = tagSchemaOps.validateFieldDefault(fieldName, spec);
765
+ if (defaultViolation) {
766
+ throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, defaultViolation.message);
760
767
  }
761
768
  }
762
769
  }
@@ -190,6 +190,33 @@ export class InvalidFieldDefaultError extends Error {
190
190
  }
191
191
  }
192
192
 
193
+ /**
194
+ * Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
195
+ * field's declared `type` isn't one of the six recognized values (vault#555
196
+ * — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
197
+ * and persisted verbatim, no error, for any NON-indexed field: the only
198
+ * existing type check, `mapFieldType`, ran solely on `indexed: true` fields
199
+ * — see {@link validateFieldType}'s doc comment). Own-field validation leaf,
200
+ * same posture as `InvalidFieldDefaultError`.
201
+ */
202
+ export class InvalidFieldTypeError extends Error {
203
+ code = "INVALID_FIELD_TYPE" as const;
204
+ error_type = "invalid_field_type" as const;
205
+ field: string;
206
+ type: string;
207
+ valid_types: readonly string[];
208
+
209
+ constructor(field: string, type: string) {
210
+ super(
211
+ `field "${field}" declares unknown type "${type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
212
+ );
213
+ this.name = "InvalidFieldTypeError";
214
+ this.field = field;
215
+ this.type = type;
216
+ this.valid_types = VALID_FIELD_TYPES;
217
+ }
218
+ }
219
+
193
220
  /**
194
221
  * Validate that a field's declared `default` (vault#553 Decision B) conforms
195
222
  * to its own `type` and (when declared) `enum`. Returns `null` when the
@@ -226,6 +253,73 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
226
253
  return null;
227
254
  }
228
255
 
256
+ /**
257
+ * The full recognized vocabulary for `TagFieldSchema.type` — storage/
258
+ * advisory validation accepts all six; only `string`/`integer`/`boolean`
259
+ * are INDEXABLE (that narrower subset is `indexed-fields.ts`'s `TYPE_MAP`,
260
+ * enforced separately via `mapFieldType` for `indexed: true` fields).
261
+ * Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
262
+ * `SchemaField.type` union — kept in lockstep by hand across the two
263
+ * deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
264
+ * for why they don't cross-import).
265
+ */
266
+ export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object"] as const;
267
+
268
+ /**
269
+ * Validate that a field's declared `type` is one of the six recognized
270
+ * values (vault#555). Returns `null` when `type` is unset (own-field checks
271
+ * elsewhere already treat an unset type as "nothing to check against") or
272
+ * recognized; otherwise a {@link TagFieldViolation} with `reason:
273
+ * "invalid_type"`.
274
+ *
275
+ * Before this, a non-indexed field's `type` was NEVER validated anywhere —
276
+ * `mapFieldType` (the only existing type check) runs solely on fields
277
+ * declaring `indexed: true`. `update-tag{fields:{weird:{type:"frobnicator"}}}`
278
+ * on a plain (non-indexed) field silently persisted the bogus type
279
+ * verbatim; every later read of that field just skipped validation
280
+ * entirely (`valueMatchesType` in schema-defaults.ts has no vocabulary
281
+ * entry for it either). This is now the SINGLE gate — indexed fields still
282
+ * get their own, narrower `unsupported_indexed_type` check (which also
283
+ * covers a RECOGNIZED-but-unindexable type like `"array"`, a different
284
+ * case from a genuinely unknown token).
285
+ *
286
+ * Pure — never throws; callers ({@link collectTagFieldViolations}'s bundled
287
+ * MCP/REST report, and the store chokepoint's defense-in-depth pre-validate)
288
+ * each decide how to surface it.
289
+ */
290
+ export function validateFieldType(field: string, spec: TagFieldSchema): TagFieldViolation | null {
291
+ if (!spec.type) return null;
292
+ if ((VALID_FIELD_TYPES as readonly string[]).includes(spec.type)) return null;
293
+ return {
294
+ field,
295
+ reason: "invalid_type",
296
+ message: `field "${field}" declares unknown type "${spec.type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Combined own-field `default` + `type` violation collection — no cross-tag
302
+ * lookups needed, so no `db` parameter (unlike
303
+ * {@link collectCrossTagFieldViolations}). Used by REST's `PUT
304
+ * /api/tags/:name` (vault#555 fix — folds these into the SAME bundled
305
+ * `tag_field_conflict` 422 report as the cross-tag checks, replacing the
306
+ * old fail-fast single-violation `invalid_field_default` 400) and reused by
307
+ * {@link collectTagFieldViolations} (MCP) so the two surfaces can't drift on
308
+ * what counts as an own-field violation.
309
+ */
310
+ export function collectOwnFieldDefaultAndTypeViolations(
311
+ incomingFields: Record<string, TagFieldSchema>,
312
+ ): TagFieldViolation[] {
313
+ const violations: TagFieldViolation[] = [];
314
+ for (const [fieldName, spec] of Object.entries(incomingFields)) {
315
+ const typeViolation = validateFieldType(fieldName, spec);
316
+ if (typeViolation) violations.push(typeViolation);
317
+ const defaultViolation = validateFieldDefault(fieldName, spec);
318
+ if (defaultViolation) violations.push(defaultViolation);
319
+ }
320
+ return violations;
321
+ }
322
+
229
323
  /** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
230
324
  function defaultMatchesType(value: unknown, type: string): boolean {
231
325
  switch (type) {
@@ -507,7 +601,8 @@ export interface TagFieldViolation {
507
601
  | "indexed_flag_conflict"
508
602
  | "unsupported_indexed_type"
509
603
  | "invalid_field_name"
510
- | "invalid_default";
604
+ | "invalid_default"
605
+ | "invalid_type";
511
606
  message: string;
512
607
  /**
513
608
  * The conflicting declarer tag — present on the cross-tag reasons
@@ -625,18 +720,24 @@ export function collectCrossTagFieldViolations(
625
720
 
626
721
  /**
627
722
  * Full field-violation collection: {@link collectCrossTagFieldViolations}
628
- * PLUS the own-field checks (unsupported type for indexing, invalid
629
- * identifier, and vault#553 Decision B a 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
634
- * improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
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.
723
+ * PLUS EVERY own-field check {@link collectOwnFieldDefaultAndTypeViolations}
724
+ * (a non-conforming `default`, vault#553 Decision B; an unrecognized `type`,
725
+ * vault#555) AND the indexed-only checks (unsupported type for indexing,
726
+ * invalid identifier). Used by the MCP `update-tag` tool, which — unlike
727
+ * REST's indexed-type/name path had no prior single-violation
728
+ * status-code contract to preserve for those two (its old inline loop threw
729
+ * an unstructured `Error` for them, same as everything else pre-#554);
730
+ * collecting everything here is a strict improvement. See
731
+ * {@link collectCrossTagFieldViolations}'s doc comment for why REST calls
732
+ * that narrower function directly for the CROSS-tag checks, then ALSO calls
733
+ * {@link collectOwnFieldDefaultAndTypeViolations} itself (vault#555 fix 5
734
+ * REST used to get `invalid_default` coverage only via
735
+ * `store.upsertTagRecord`'s fail-fast pre-validate, a single-violation
736
+ * `InvalidFieldDefaultError` → 400 that silently dropped every OTHER bad
737
+ * field in the same call; both surfaces now report every own-field default/
738
+ * type violation together, bundled with the cross-tag ones) — the
739
+ * indexed-type/name pair stays REST's own single-violation `400
740
+ * invalid_indexed_field` path (unchanged wire contract, vault#478).
640
741
  */
641
742
  export function collectTagFieldViolations(
642
743
  db: Database,
@@ -644,14 +745,9 @@ export function collectTagFieldViolations(
644
745
  incomingFields: Record<string, TagFieldSchema>,
645
746
  ): TagFieldViolation[] {
646
747
  const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
748
+ violations.push(...collectOwnFieldDefaultAndTypeViolations(incomingFields));
647
749
 
648
750
  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
-
655
751
  if (spec.indexed === true) {
656
752
  const mapped = mapFieldType(spec.type);
657
753
  if (!mapped) {