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

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/src/routes.ts CHANGED
@@ -22,9 +22,16 @@ import {
22
22
  type QueryWarning,
23
23
  } from "../core/src/query-warnings.ts";
24
24
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
25
- import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
25
+ import {
26
+ listUnresolvedWikilinks,
27
+ resolveOrQueueLink,
28
+ resolveStructuredLinkNote,
29
+ getUnresolvedLinksForNote,
30
+ getUnresolvedLinksForNotes,
31
+ } from "../core/src/wikilinks.ts";
26
32
  import { transactionAsync } from "../core/src/txn.ts";
27
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
33
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
34
+ import { normalizePath } from "../core/src/paths.ts";
28
35
  import {
29
36
  parseContentRange,
30
37
  applyContentRange,
@@ -52,10 +59,11 @@ import {
52
59
  scrubParentCycleError,
53
60
  scrubReferencingTagsByScope,
54
61
  scrubTagFieldViolationsByScope,
62
+ scrubValidationStatusByScope,
55
63
  tagScopeForbidden,
56
64
  tagsWithinScope,
57
65
  } from "./tag-scope.ts";
58
- import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
66
+ import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
59
67
  import { findTokensReferencingTag } from "./token-store.ts";
60
68
 
61
69
  /**
@@ -811,6 +819,9 @@ export function parseNotesQueryOpts(url: URL): {
811
819
  excludeTags: parseQueryList(url, "exclude_tag"),
812
820
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
813
821
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
822
+ // Presence filter on dangling outbound wikilinks/structured links
823
+ // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
824
+ hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
814
825
  path: parseQuery(url, "path") ?? undefined,
815
826
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
816
827
  extension: parseExtensionFilter(url),
@@ -1016,6 +1027,13 @@ async function handleNotesInner(
1016
1027
  tagScope.raw,
1017
1028
  );
1018
1029
  }
1030
+ if (parseBool(parseQuery(url, "include_broken_links"), false)) {
1031
+ // No tag-scope filtering needed (unlike include_links above): a
1032
+ // broken-link `target` never resolved to a note, so there's no
1033
+ // neighbor identity/content to leak — just the string this note's
1034
+ // own [[wikilink]]/structured link already named.
1035
+ result.broken_links = getUnresolvedLinksForNote(db, note.id);
1036
+ }
1019
1037
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
1020
1038
  result.attachments = await store.getAttachments(note.id);
1021
1039
  }
@@ -1335,6 +1353,7 @@ async function handleNotesInner(
1335
1353
  const contentRange = parseContentRangeQuery(url, includeContent);
1336
1354
  if (contentRange.error) return contentRange.error;
1337
1355
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
1356
+ const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
1338
1357
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
1339
1358
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1340
1359
  const inclMeta = parseIncludeMetadata(url);
@@ -1352,6 +1371,34 @@ async function handleNotesInner(
1352
1371
  if (contentRange.range && includeContent) {
1353
1372
  for (const n of output) applyContentRange(n, contentRange.range);
1354
1373
  }
1374
+ // vault#555 fix 3 — attach validation_status on reads too (mirrors
1375
+ // MCP query-notes). Additive, present only when a tag declares
1376
+ // `fields`. Runs BEFORE metadata filtering so it sees full metadata
1377
+ // regardless of `include_metadata`. Scrubbed for a tag-scoped caller
1378
+ // (vault#555 auth review) so an out-of-scope co-tag's schema shape
1379
+ // doesn't leak — `results` here is already scope-filtered, but a
1380
+ // surviving note may carry an out-of-scope co-tag whose schema would
1381
+ // otherwise appear in its validation_status. No-op when unscoped.
1382
+ {
1383
+ const statusById = new Map(
1384
+ results.map((n) => [
1385
+ n.id,
1386
+ scrubValidationStatusByScope(
1387
+ store.validateNoteAgainstSchemas({
1388
+ path: n.path,
1389
+ tags: n.tags,
1390
+ metadata: n.metadata as Record<string, unknown> | undefined,
1391
+ }),
1392
+ tagScope.allowed,
1393
+ tagScope.raw,
1394
+ ),
1395
+ ]),
1396
+ );
1397
+ for (const n of output as any[]) {
1398
+ const status = statusById.get(n.id);
1399
+ if (status) n.validation_status = status;
1400
+ }
1401
+ }
1355
1402
  if (inclMeta !== undefined && inclMeta !== true) {
1356
1403
  output = output.map((n: any) => filterMetadata(n, inclMeta));
1357
1404
  }
@@ -1398,13 +1445,19 @@ async function handleNotesInner(
1398
1445
  );
1399
1446
  }
1400
1447
 
1401
- if (includeLinks || includeAttachments) {
1448
+ if (includeLinks || includeBrokenLinks || includeAttachments) {
1402
1449
  // Whole-page link hydration in a constant number of queries — the
1403
1450
  // per-note variant cost (1 link query + 1 summary query + N tag
1404
1451
  // queries) × page size. 2026-06-10 perf measurements.
1405
1452
  const linksByNote = includeLinks
1406
1453
  ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1407
1454
  : null;
1455
+ // Same batched-per-page shape as links (vault#555). No tag-scope
1456
+ // filtering needed — a broken-link `target` never resolved to a
1457
+ // note, so there's no neighbor identity to leak.
1458
+ const brokenLinksByNote = includeBrokenLinks
1459
+ ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
1460
+ : null;
1408
1461
  const enrichedOut: any[] = [];
1409
1462
  for (const n of output) {
1410
1463
  const enriched: any = { ...n };
@@ -1416,6 +1469,7 @@ async function handleNotesInner(
1416
1469
  tagScope.raw,
1417
1470
  );
1418
1471
  }
1472
+ if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
1419
1473
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
1420
1474
  enrichedOut.push(enriched);
1421
1475
  }
@@ -1475,13 +1529,111 @@ async function handleNotesInner(
1475
1529
  }
1476
1530
 
1477
1531
  const created: Note[] = [];
1532
+ // Structured `links` are resolved in a second pass, after every note
1533
+ // in this batch has been created (vault#555) — resolving inline (the
1534
+ // old behavior) meant a link from item 0 to item 1's path silently
1535
+ // dropped, since item 1 didn't exist yet at the moment item 0's links
1536
+ // were processed. Mirrors the MCP create-note fix exactly (both
1537
+ // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1538
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1539
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
1540
+ // `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
1541
+ // create-note tool exactly (same contract, independent REST-layer
1542
+ // reimplementation sharing the same core primitives). See that file's
1543
+ // doc comments for the full rationale.
1544
+ const existedMap = new Map<string, boolean>();
1545
+ const noMutateIds = new Set<string>();
1546
+ const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
1547
+ existedMap.set(resultNote.id, true);
1548
+ if (noMutate) noMutateIds.add(resultNote.id);
1549
+ created.push(resultNote);
1550
+ };
1551
+ const applyExistingNote = async (
1552
+ item: any,
1553
+ existingNote: Note,
1554
+ mode: "ignore" | "update" | "replace",
1555
+ ): Promise<{ note: Note; noMutate: boolean }> => {
1556
+ // CRITICAL scope guard (vault#555 auth-review must-fix): `existingNote`
1557
+ // was resolved VAULT-WIDE by path (store.getNoteByPath) at BOTH call
1558
+ // sites below (proactive + race-backstop). The batch's incoming-tag
1559
+ // pre-validation guards only each item's OWN tags — NOT this resolved
1560
+ // note — so without this a token scoped to `public` could READ (ignore)
1561
+ // or OVERWRITE (update/replace) a note carrying only an out-of-scope
1562
+ // tag `secret` just by naming its path. When the note is outside the
1563
+ // caller's tag scope, treat it as a path conflict — the path is taken,
1564
+ // but invisible to this caller — throwing BEFORE any read/mutation so
1565
+ // we never expose content or write. `noteWithinTagScope` is a no-op for
1566
+ // unscoped tokens (tagScope.raw === null → always true). The throw
1567
+ // propagates to runBatch's PATH_CONFLICT catch → 409 path_conflict,
1568
+ // byte-identical to a genuine conflict (leaks nothing about WHY).
1569
+ if (!noteWithinTagScope(existingNote, tagScope.allowed, tagScope.raw)) {
1570
+ throw new PathConflictError(existingNote.path ?? "");
1571
+ }
1572
+ if (mode === "ignore") {
1573
+ return { note: existingNote, noMutate: true };
1574
+ }
1575
+ const updates: { content?: string; metadata?: Record<string, unknown> } = {};
1576
+ if (mode === "update") {
1577
+ if (item.content !== undefined) updates.content = item.content;
1578
+ if (item.metadata !== undefined) {
1579
+ updates.metadata = mergeMetadata(
1580
+ existingNote.metadata as Record<string, unknown> | null | undefined,
1581
+ item.metadata,
1582
+ );
1583
+ }
1584
+ } else {
1585
+ // "replace" — PUT semantics: content/metadata become exactly this
1586
+ // payload (empty default when omitted), never merged.
1587
+ updates.content = item.content ?? "";
1588
+ updates.metadata = item.metadata ?? {};
1589
+ }
1590
+
1591
+ const incomingTags: string[] = item.tags ?? [];
1592
+ const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
1593
+ const projectedMetadata = updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
1594
+ gateStrictWrite(store, writeCtx, { path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
1595
+
1596
+ // vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): gate the
1597
+ // core UPDATE on the tag/link mutation too, not just `updates` — a
1598
+ // tags-only/links-only "update" leaves `updates` empty, and skipping
1599
+ // store.updateNote would freeze `updated_at` even though store.tagNote
1600
+ // genuinely mutates the note (breaking cursor polling + updated_at
1601
+ // sync). Mirrors the update-note/PATCH hasTagMutation||hasLinkMutation
1602
+ // gate. "replace" always sets content+metadata, so it was unaffected.
1603
+ const hasLinkMutation = item.links !== undefined;
1604
+ let result: Note = existingNote;
1605
+ if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
1606
+ result = await store.updateNote(existingNote.id, {
1607
+ ...updates,
1608
+ actor: writeCtx.actor,
1609
+ via: writeCtx.via,
1610
+ });
1611
+ }
1612
+
1613
+ if (incomingTags.length > 0) {
1614
+ await store.tagNote(result.id, incomingTags);
1615
+ // Redundant with the outer batch loop's applySchemaDefaults (this
1616
+ // note isn't in `noMutateIds` — only "ignore" hits are), but harmless
1617
+ // (idempotent) — kept so the update/replace branch is self-contained.
1618
+ await applySchemaDefaults(store, db, [result.id], incomingTags);
1619
+ }
1620
+
1621
+ if (item.links) {
1622
+ pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
1623
+ }
1624
+
1625
+ return { note: (await store.getNote(result.id)) ?? result, noMutate: false };
1626
+ };
1627
+
1478
1628
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
1479
1629
  // failure (path conflict, etc.) rolls back every prior insert. Without
1480
1630
  // this, callers got half-applied batches where the prefix landed and
1481
1631
  // the offending entry surfaced the 409 — see #236. Single-item posts
1482
1632
  // are already atomic at the store layer and skip the wrap so they
1483
1633
  // don't collide with concurrent single-item callers on the shared
1484
- // bun:sqlite connection.
1634
+ // bun:sqlite connection. A caught PATH_CONFLICT (the if_exists race
1635
+ // backstop below) does NOT roll back the transaction — only a throw
1636
+ // that escapes `runBatch` does (see core/src/txn.ts).
1485
1637
  const batched = items.length > 1;
1486
1638
  const runBatch = async (): Promise<void> => {
1487
1639
  for (const item of items) {
@@ -1491,6 +1643,26 @@ async function handleNotesInner(
1491
1643
  const extension = item.extension !== undefined
1492
1644
  ? validateExtension(item.extension)
1493
1645
  : undefined;
1646
+ const effectiveExtension = extension ?? "md";
1647
+ const ifExists: string = item.if_exists ?? "error";
1648
+ const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
1649
+
1650
+ // Proactive existence check (vault#555) — only possible with a
1651
+ // path. Handles the common, non-racing case cleanly: skips the
1652
+ // raw-item strict gate below (it would validate the wrong shape —
1653
+ // see `applyExistingNote`) and goes straight to the collision
1654
+ // branch. The catch below is the backstop for a true race.
1655
+ let existingNote: Note | null = null;
1656
+ if (upsertMode && item.path) {
1657
+ const normalized = normalizePath(item.path);
1658
+ existingNote = normalized ? await store.getNoteByPath(normalized, effectiveExtension) : null;
1659
+ }
1660
+ if (existingNote) {
1661
+ const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
1662
+ recordExistedBranch(resultNote, noMutate);
1663
+ continue;
1664
+ }
1665
+
1494
1666
  // Strict-schema gate (vault#299) — reject before any write so a
1495
1667
  // mid-batch violation rolls back the batch transaction.
1496
1668
  gateStrictWrite(store, writeCtx, {
@@ -1498,28 +1670,67 @@ async function handleNotesInner(
1498
1670
  tags: item.tags,
1499
1671
  metadata: item.metadata,
1500
1672
  });
1501
- const note = await store.createNote(item.content ?? "", {
1502
- id: item.id,
1503
- path: item.path,
1504
- tags: item.tags,
1505
- metadata: item.metadata,
1506
- created_at: item.createdAt ?? item.created_at,
1507
- ...(extension !== undefined ? { extension } : {}),
1508
- // Write-attribution (vault#298) — REST batch create.
1509
- actor: writeCtx.actor,
1510
- via: writeCtx.via,
1511
- });
1673
+ let note: Note;
1674
+ try {
1675
+ note = await store.createNote(item.content ?? "", {
1676
+ id: item.id,
1677
+ path: item.path,
1678
+ tags: item.tags,
1679
+ metadata: item.metadata,
1680
+ created_at: item.createdAt ?? item.created_at,
1681
+ ...(extension !== undefined ? { extension } : {}),
1682
+ // Write-attribution (vault#298) — REST batch create.
1683
+ actor: writeCtx.actor,
1684
+ via: writeCtx.via,
1685
+ });
1686
+ } catch (err: any) {
1687
+ // Race backstop (vault#555): a concurrent writer's INSERT
1688
+ // committed between our proactive check and this one.
1689
+ // Re-resolve the now-existing row and fall through to the SAME
1690
+ // branch a proactive hit would have taken.
1691
+ if (upsertMode && err?.code === "PATH_CONFLICT") {
1692
+ const winner = await store.getNoteByPath(err.path, effectiveExtension);
1693
+ if (winner) {
1694
+ const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
1695
+ recordExistedBranch(resultNote, noMutate);
1696
+ continue;
1697
+ }
1698
+ }
1699
+ throw err;
1700
+ }
1701
+
1702
+ if (upsertMode) existedMap.set(note.id, false);
1512
1703
 
1513
- // Create explicit links
1514
1704
  if (item.links) {
1515
- for (const link of item.links as { target: string; relationship: string }[]) {
1516
- const target = await resolveNote(store, link.target);
1517
- if (target) await store.createLink(note.id, target.id, link.relationship);
1518
- }
1705
+ pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
1519
1706
  }
1520
1707
 
1521
1708
  created.push((await store.getNote(note.id)) ?? note);
1522
1709
  }
1710
+
1711
+ // --- Resolve structured links (vault#555) ---
1712
+ // Same semantics as [[wikilinks]]: ID or path/title match, tried now
1713
+ // that every sibling note in this batch exists. A target that still
1714
+ // doesn't resolve is queued for lazy resolution (backfills
1715
+ // automatically when a matching note is created later) and surfaces
1716
+ // an `unresolved_link` warning naming the target — never silent.
1717
+ for (const { sourceId, links } of pendingLinks) {
1718
+ for (const link of links) {
1719
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1720
+ if (targetId) {
1721
+ await store.createLink(sourceId, targetId, link.relationship);
1722
+ } else {
1723
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1724
+ list.push({
1725
+ code: "unresolved_link",
1726
+ 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.`,
1727
+ target: link.target,
1728
+ relationship: link.relationship,
1729
+ });
1730
+ linkWarningsByNote.set(sourceId, list);
1731
+ }
1732
+ }
1733
+ }
1523
1734
  };
1524
1735
  try {
1525
1736
  await (batched ? transactionAsync(db, runBatch) : runBatch());
@@ -1567,6 +1778,10 @@ async function handleNotesInner(
1567
1778
  // applied so the common no-defaults path adds zero extra reads.
1568
1779
  const mutatedIds = new Set<string>();
1569
1780
  for (const note of created) {
1781
+ // "ignore" hits (vault#555) promise ZERO mutation, including
1782
+ // schema-default backfill of a since-added default the existing
1783
+ // note predates — skip them entirely.
1784
+ if (noMutateIds.has(note.id)) continue;
1570
1785
  if (note.tags?.length) {
1571
1786
  for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1572
1787
  mutatedIds.add(id);
@@ -1584,8 +1799,37 @@ async function handleNotesInner(
1584
1799
  // Attach `validation_status` so HTTP create-note matches the MCP
1585
1800
  // surface (vault#287). `attachValidationStatus` returns the note
1586
1801
  // unchanged when no tag declares fields, so vaults without any tag
1587
- // schemas see no behavior change.
1588
- const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1802
+ // schemas see no behavior change. Fold in any `unresolved_link`
1803
+ // warnings (vault#555) additive, present only when this note's
1804
+ // `links` had a target that didn't resolve. `existed` (vault#555) is
1805
+ // attached ONLY for items whose `if_exists` actually engaged — the
1806
+ // default "error" path's response shape is untouched.
1807
+ const final = refreshed.map((n) => {
1808
+ const validated = attachValidationStatus(store, db, n);
1809
+ const warnings = linkWarningsByNote.get(n.id);
1810
+ const existed = existedMap.get(n.id);
1811
+ let out: any = validated;
1812
+ if (warnings && warnings.length > 0) out = { ...out, warnings };
1813
+ if (existed !== undefined) out = { ...out, existed };
1814
+ return out;
1815
+ });
1816
+
1817
+ // Batch summary (vault#555) — compact shape instead of N full note
1818
+ // objects. Batch-only (a `notes` array); `summary` is ignored on a
1819
+ // single-note POST. Mirrors the MCP create-note tool exactly.
1820
+ if (body.notes && body.summary === true) {
1821
+ return json(
1822
+ {
1823
+ created: final.filter((n: any) => n.existed !== true).length,
1824
+ ids: final.map((n: any) => n.id),
1825
+ // Reserved for future partial-batch-failure reporting — a batch
1826
+ // create is all-or-nothing today, so this is always empty.
1827
+ failed: [] as unknown[],
1828
+ },
1829
+ 201,
1830
+ );
1831
+ }
1832
+
1589
1833
  return json(body.notes ? final : final[0], 201);
1590
1834
  }
1591
1835
 
@@ -1741,6 +1985,24 @@ async function handleNotesInner(
1741
1985
  const contentRange = parseContentRangeQuery(url, includeContent);
1742
1986
  if (contentRange.error) return contentRange.error;
1743
1987
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
1988
+ // vault#555 fix 3 — mirror the MCP query-notes fix: attach
1989
+ // validation_status on reads too, not just on the one-time create/update
1990
+ // write response. See core/src/mcp.ts's query-notes handler for the
1991
+ // full rationale. Scrubbed for a tag-scoped caller (vault#555 auth
1992
+ // review) so an out-of-scope co-tag's schema shape doesn't leak — no-op
1993
+ // for unscoped callers (tagScope.raw === null).
1994
+ {
1995
+ const status = scrubValidationStatusByScope(
1996
+ store.validateNoteAgainstSchemas({
1997
+ path: note.path,
1998
+ tags: note.tags,
1999
+ metadata: note.metadata as Record<string, unknown> | undefined,
2000
+ }),
2001
+ tagScope.allowed,
2002
+ tagScope.raw,
2003
+ );
2004
+ if (status) result.validation_status = status;
2005
+ }
1744
2006
  const expand = parseExpandParams(url, db, tagScope);
1745
2007
  if (expand && includeContent && typeof result.content === "string") {
1746
2008
  expand.ctx.expanded.add(note.id);
@@ -1834,22 +2096,35 @@ async function handleNotesInner(
1834
2096
  // fresh note).
1835
2097
  // - Missing target notes skip silently (mirrors MCP).
1836
2098
  const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
2099
+ // `unresolved_link` warnings (vault#555) — a target that doesn't
2100
+ // resolve is queued for lazy resolution (backfills automatically
2101
+ // when a matching note is created later), never silently dropped.
2102
+ const createWarnings: QueryWarning[] = [];
1837
2103
  if (linksAdd) {
1838
2104
  for (const link of linksAdd) {
1839
- const target = await resolveNote(store, link.target);
1840
- if (target) {
1841
- await store.createLink(created.id, target.id, link.relationship, link.metadata);
2105
+ const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2106
+ if (targetId) {
2107
+ await store.createLink(created.id, targetId, link.relationship, link.metadata);
2108
+ } else {
2109
+ createWarnings.push({
2110
+ code: "unresolved_link",
2111
+ 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.`,
2112
+ target: link.target,
2113
+ relationship: link.relationship,
2114
+ });
1842
2115
  }
1843
2116
  }
1844
2117
  }
1845
2118
  const final = await store.getNote(created.id);
1846
2119
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
1847
- const validated = attachValidationStatus(store, db, final);
2120
+ const validated: any = attachValidationStatus(store, db, final);
2121
+ if (createWarnings.length > 0) validated.warnings = createWarnings;
1848
2122
  const includeContentResp = body.include_content !== false;
1849
2123
  if (includeContentResp) return json({ ...validated, created: true });
1850
2124
  const lean: any = toNoteIndex(validated);
1851
2125
  const vs = (validated as any).validation_status;
1852
2126
  if (vs !== undefined) lean.validation_status = vs;
2127
+ if (createWarnings.length > 0) lean.warnings = createWarnings;
1853
2128
  lean.created = true;
1854
2129
  return json(lean);
1855
2130
  }
@@ -1992,7 +2267,7 @@ async function handleNotesInner(
1992
2267
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1993
2268
  if (linksRemove) {
1994
2269
  for (const link of linksRemove) {
1995
- const target = await resolveNote(store, link.target);
2270
+ const target = resolveStructuredLinkNote(db, link.target);
1996
2271
  if (!target) continue;
1997
2272
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1998
2273
  if (link.relationship === "wikilink" && target.path) {
@@ -2069,9 +2344,21 @@ async function handleNotesInner(
2069
2344
  gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
2070
2345
  }
2071
2346
 
2072
- if (Object.keys(updates).length > 0) {
2347
+ // vault#555 fix 2 — tag and link mutations must bump `updated_at` too,
2348
+ // not just core-field (content/path/metadata) changes. Mirrors the MCP
2349
+ // `update-note` fix exactly: a tags-only or links-only PATCH with
2350
+ // `force: true` (no `if_updated_at`) left `updates` empty, so
2351
+ // `store.updateNote` was never even called and `updated_at` never
2352
+ // moved despite a real tags/note_tags or links change.
2353
+ const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
2354
+ const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
2355
+ if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
2073
2356
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
2074
- // write columns on the same UPDATE that bumps updated_at.
2357
+ // write columns on the same UPDATE that bumps updated_at. `updates`
2358
+ // may carry no core fields at all (a pure tag/link mutation) — that's
2359
+ // fine: `noteOps.updateNote` unconditionally SETs
2360
+ // `updated_at`/`last_updated_by`/`last_updated_via` whenever
2361
+ // `skipUpdatedAt` isn't set, so this still issues a real UPDATE.
2075
2362
  updates.actor = writeCtx.actor;
2076
2363
  updates.via = writeCtx.via;
2077
2364
  await store.updateNote(note.id, updates);
@@ -2091,11 +2378,24 @@ async function handleNotesInner(
2091
2378
  await store.untagNote(note.id, body.tags.remove);
2092
2379
  }
2093
2380
 
2094
- // Add links
2381
+ // Add links. `unresolved_link` warnings (vault#555) — a target that
2382
+ // doesn't resolve is queued for lazy resolution (backfills
2383
+ // automatically when a matching note is created later), never
2384
+ // silently dropped.
2385
+ const linkWarnings: QueryWarning[] = [];
2095
2386
  if (body.links?.add) {
2096
2387
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
2097
- const target = await resolveNote(store, link.target);
2098
- if (target) await store.createLink(note.id, target.id, link.relationship, link.metadata);
2388
+ const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2389
+ if (targetId) {
2390
+ await store.createLink(note.id, targetId, link.relationship, link.metadata);
2391
+ } else {
2392
+ linkWarnings.push({
2393
+ code: "unresolved_link",
2394
+ 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.`,
2395
+ target: link.target,
2396
+ relationship: link.relationship,
2397
+ });
2398
+ }
2099
2399
  }
2100
2400
  }
2101
2401
 
@@ -2130,6 +2430,7 @@ async function handleNotesInner(
2130
2430
  tagScope.raw,
2131
2431
  );
2132
2432
  }
2433
+ if (linkWarnings.length > 0) validated.warnings = linkWarnings;
2133
2434
  const includeContentResp = body.include_content !== false;
2134
2435
  // `created: false` is appended to every update-path response so
2135
2436
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -2142,6 +2443,7 @@ async function handleNotesInner(
2142
2443
  // Carry the link echo across the lean conversion — `toNoteIndex`
2143
2444
  // drops unknown fields, same as the `validation_status` recipe above.
2144
2445
  if (validated.links !== undefined) lean.links = validated.links;
2446
+ if (linkWarnings.length > 0) lean.warnings = linkWarnings;
2145
2447
  lean.created = false;
2146
2448
  return json(lean);
2147
2449
  } catch (e: any) {
@@ -2642,11 +2944,22 @@ export async function handleTags(
2642
2944
  // — silent 200s on main — is non-indexed type conflicts and indexed-flag
2643
2945
  // conflicts. See `collectCrossTagFieldViolations`'s doc comment.
2644
2946
  if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
2947
+ const incomingFields = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
2948
+ // vault#555 fix 5 — fold the own-field `default`/`type` checks into
2949
+ // the SAME bundled report as the cross-tag ones. Before this, a bad
2950
+ // `default` (and, since fix 4 added the check, an unrecognized
2951
+ // `type`) went through `store.upsertTagRecord`'s fail-fast pre-
2952
+ // validate below INSTEAD — a single-violation 400 that silently never
2953
+ // mentioned any OTHER bad field in the same call. `unsupported_
2954
+ // indexed_type`/`invalid_field_name` deliberately stay OUT of this
2955
+ // bundle — REST's established single-violation `400
2956
+ // invalid_indexed_field` contract for those two is unchanged
2957
+ // (vault#478; see `collectCrossTagFieldViolations`'s doc comment).
2645
2958
  const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
2646
2959
  store.db,
2647
2960
  putTagName,
2648
- body.fields as Record<string, tagSchemaOps.TagFieldSchema>,
2649
- );
2961
+ incomingFields,
2962
+ ).concat(tagSchemaOps.collectOwnFieldDefaultAndTypeViolations(incomingFields));
2650
2963
  if (fieldViolations.length > 0) {
2651
2964
  // Tag-scope scrub (vault#554 auth-and-scope fold): the write is
2652
2965
  // still rejected, but a violation whose conflicting declarer is
@@ -2694,15 +3007,26 @@ export async function handleTags(
2694
3007
  if (err instanceof InvalidFieldDefaultError) {
2695
3008
  // vault#553 Decision B — a declared `default` doesn't conform to its
2696
3009
  // own field's type/enum. Own-field error (no cross-tag data), so no
2697
- // scrub needed. Mirrors IndexedFieldError's 400 shape/posturesee
2698
- // store.upsertTagRecord's pre-validate comment for why REST hits
2699
- // this (fail-fast, single violation) while MCP's update-tag usually
2700
- // reports it bundled via `TagFieldConflictError` instead.
3010
+ // scrub needed. DEFENSE-IN-DEPTH ONLY as of vault#555 fix 5 the
3011
+ // pre-check above (`collectOwnFieldDefaultAndTypeViolations`) now
3012
+ // catches every `invalid_default` BEFORE reaching `store.upsertTagRecord`
3013
+ // and reports it bundled via `tag_field_conflict` 422 instead; this
3014
+ // branch only fires for a caller that somehow bypasses that pre-check.
2701
3015
  return json(
2702
3016
  { error: err.message, error_type: "invalid_field_default", field: err.field },
2703
3017
  400,
2704
3018
  );
2705
3019
  }
3020
+ if (err instanceof InvalidFieldTypeError) {
3021
+ // vault#555 fix 4 — a declared `type` isn't one of the six
3022
+ // recognized values. Same defense-in-depth posture as
3023
+ // InvalidFieldDefaultError above — the pre-check catches this in
3024
+ // the normal path and reports it bundled instead.
3025
+ return json(
3026
+ { error: err.message, error_type: "invalid_field_type", field: err.field, type: err.type, valid_types: err.valid_types },
3027
+ 400,
3028
+ );
3029
+ }
2706
3030
  if (err instanceof ParentCycleError) {
2707
3031
  // vault#552: parent_names would close a cycle. Nothing was
2708
3032
  // persisted. Scope-scrub the cycle path for a tag-scoped caller —
@@ -330,4 +330,48 @@ describe("invalid_indexed_field × tag scope — the 400 door is scrubbed too (w
330
330
  expect(caught).toBeInstanceOf(IndexedFieldError);
331
331
  expect(caught.message).toContain("mine-too");
332
332
  });
333
+
334
+ // vault#555 fix 4/5 — `invalid_type` and `invalid_default` are own-field
335
+ // violations (no `other_tag`, no cross-tag data to leak) so
336
+ // `scrubTagFieldViolationsByScope` is a documented no-op for them by
337
+ // construction. Pin that a scoped caller still gets the FULL violation
338
+ // (field/reason/message) — nothing accidentally strips it.
339
+ test("invalid_type and invalid_default violations pass through the scope-scrub untouched (no other_tag to scrub)", async () => {
340
+ seedVault("journal");
341
+ getVaultStore("journal");
342
+
343
+ // REST
344
+ const res = await handleTags(
345
+ new Request("http://localhost/api/tags/mine", {
346
+ method: "PUT",
347
+ body: JSON.stringify({
348
+ fields: {
349
+ weird: { type: "frobnicator" },
350
+ bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
351
+ },
352
+ }),
353
+ }),
354
+ getVaultStore("journal"),
355
+ "/mine",
356
+ { allowed: new Set(["mine"]), raw: ["mine"] },
357
+ );
358
+ expect(res.status).toBe(422);
359
+ const body: any = await res.json();
360
+ expect(body.violations).toHaveLength(2);
361
+ const byField = new Map(body.violations.map((v: any) => [v.field, v]));
362
+ expect(byField.get("weird").reason).toBe("invalid_type");
363
+ expect(byField.get("weird").other_tag).toBeUndefined();
364
+ expect(byField.get("bad_default").reason).toBe("invalid_default");
365
+
366
+ // MCP
367
+ const tool = await updateTagTool("journal", ["mine"]);
368
+ let caught: any;
369
+ try {
370
+ await tool.execute({ tag: "mine", fields: { weird: { type: "frobnicator" } } });
371
+ } catch (e) {
372
+ caught = e;
373
+ }
374
+ expect(caught.violations).toHaveLength(1);
375
+ expect(caught.violations[0].reason).toBe("invalid_type");
376
+ });
333
377
  });