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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/core/src/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
package/src/routes.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
+ import type { Database } from "bun:sqlite";
14
15
  import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
15
16
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
17
  import {
@@ -30,7 +31,18 @@ import {
30
31
  resolveStructuredLinkNote,
31
32
  getUnresolvedLinksForNote,
32
33
  getUnresolvedLinksForNotes,
34
+ listZeroVisibleAmbiguousAsUnresolved,
35
+ listResolvedToInvisibleAsUnresolved,
36
+ narrowByVisibleBrokenness,
37
+ sqlHasBrokenLinks,
38
+ getAmbiguousLinksForNote,
39
+ getAmbiguousLinksForNotes,
40
+ narrowByVisibleAmbiguity,
41
+ sqlHasAmbiguousLinks,
33
42
  getContentWikilinkWarnings,
43
+ ambiguousLinkWarning,
44
+ unresolvedLinkWarning,
45
+ narrowLinkWarningsForVisibility,
34
46
  } from "../core/src/wikilinks.ts";
35
47
  import { transactionAsync } from "../core/src/txn.ts";
36
48
  import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
@@ -67,6 +79,7 @@ import {
67
79
  filterHydratedLinksByTagScope,
68
80
  filterNotesByTagScope,
69
81
  noteWithinTagScope,
82
+ scopeQueryTags,
70
83
  scrubIndexedFieldConflictError,
71
84
  scrubNotesTagsByScope,
72
85
  scrubNoteTagsByScope,
@@ -901,8 +914,66 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
901
914
  * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
902
915
  * as the inline code did. `hasSearch` is surfaced from the raw `search` param
903
916
  * (this helper does not itself build a search query — the caller routes that).
917
+ *
918
+ * `tagScope` (vault#675): out-of-scope `tag=` / `exclude_tag=` inputs are
919
+ * rewritten so they match nothing, exactly as a nonexistent tag would — see
920
+ * `scopeQueryTags`. Applied here so every branch that lowers a query string
921
+ * (structured, `search`, `semantic`, `aggregate`, `near`, cursor) is covered
922
+ * by one call, the same way this helper already gives subscribe/search
923
+ * predicate parity. Default `NO_TAG_SCOPE` → no-op, so the WS route's
924
+ * upgrade-time parse (which runs before the socket authenticates) is
925
+ * unchanged; `ws-server.ts` applies the scoping once auth is known.
926
+ */
927
+ /**
928
+ * The `visible` predicate the vault#581 ambiguous-links surface consults for
929
+ * a TAG-SCOPED reader — `undefined` (and therefore a complete no-op) for an
930
+ * unscoped one. Identical shape and rule to the MCP door's `ambiguityVisible`
931
+ * (`src/mcp-tools.ts`) and to `nearTraversable`: look a CANDIDATE note's tags
932
+ * up by id and apply the same `noteWithinTagScope` test every other read path
933
+ * uses, so the two doors cannot drift on who counts as a visible candidate.
934
+ *
935
+ * Needed because `candidate_count` is derived vault-wide: without it a
936
+ * `work`-scoped reader learns from `candidate_count: 2` on its own `[[Dup]]`
937
+ * that a second `Dup` exists in a scope it cannot see.
938
+ *
939
+ * vault#239 reuses the SAME predicate for the BROKEN-links surface — "which
940
+ * of this target's candidates can the reader see" is one question, and the
941
+ * ambiguous (>=2 visible) and broken (0 visible) answers are two faces of it.
942
+ */
943
+ function ambiguityVisibilityFor(
944
+ db: Database,
945
+ tagScope: TagScopeCtx,
946
+ ): ((noteId: string) => boolean) | undefined {
947
+ if (tagScope.raw === null) return undefined;
948
+ return (noteId: string) =>
949
+ noteWithinTagScope(
950
+ { id: noteId, tags: getNoteTags(db, noteId) } as Note,
951
+ tagScope.allowed,
952
+ tagScope.raw,
953
+ );
954
+ }
955
+
956
+ /**
957
+ * Re-decide a WRITE response's `ambiguous_link` warnings against the caller's
958
+ * own sub-vault. `create-note`/`update-note` echo per-note `warnings`, and an
959
+ * `ambiguous_link` entry carries a `candidate_count` derived VAULT-WIDE — so
960
+ * without this a `work`-scoped writer saving `[[Dup]]` learns from
961
+ * `candidate_count: 2` that a second `Dup` exists in a scope it cannot see.
962
+ * That is exactly the vault#707 read-side oracle, reachable through the write
963
+ * door. Same predicate ({@link ambiguityVisibilityFor}) and same core decision
964
+ * function as the read surfaces, so the doors cannot drift. Unscoped: the
965
+ * predicate is `undefined` and the array is returned unchanged.
904
966
  */
905
- export function parseNotesQueryOpts(url: URL): {
967
+ function narrowWriteWarnings(
968
+ db: Database,
969
+ warnings: QueryWarning[],
970
+ tagScope: TagScopeCtx,
971
+ ): QueryWarning[] {
972
+ const visible = ambiguityVisibilityFor(db, tagScope);
973
+ return visible ? narrowLinkWarningsForVisibility(db, warnings, visible) : warnings;
974
+ }
975
+
976
+ export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCOPE): {
906
977
  queryOpts?: QueryOpts;
907
978
  hasSearch: boolean;
908
979
  hasNear: boolean;
@@ -956,10 +1027,29 @@ export function parseNotesQueryOpts(url: URL): {
956
1027
  expand,
957
1028
  excludeTags: parseQueryList(url, "exclude_tag"),
958
1029
  hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
959
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
1030
+ hasLinks: linkOps.sqlHasLinks(parseBoolOrUndef(parseQuery(url, "has_links")), tagScope.raw !== null),
960
1031
  // Presence filter on dangling outbound wikilinks/structured links
961
1032
  // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
962
- hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1033
+ // vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
1034
+ // into SQL — a note can be broken in that reader's sub-vault while
1035
+ // carrying no `unresolved_wikilinks` row at all (its target's only
1036
+ // candidates are invisible, so the row sits in `ambiguous_wikilinks`
1037
+ // instead). Both are lifted here and re-decided per note by
1038
+ // `narrowByVisibleBrokenness`. Identity for an unscoped reader.
1039
+ hasBrokenLinks: sqlHasBrokenLinks(
1040
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1041
+ tagScope.raw !== null,
1042
+ ),
1043
+ // Presence filter on AMBIGUOUS outbound links — a target that matched ≥2
1044
+ // notes (vault#581) — see core/src/types.ts QueryOpts.hasAmbiguousLinks.
1045
+ // vault#581 auth review: for a TAG-SCOPED reader only the `true` polarity
1046
+ // is safe to push into SQL (it's a superset of the right answer);
1047
+ // `false` is lifted here and both are re-decided per note by
1048
+ // `narrowByVisibleAmbiguity` on the reader's own visible sub-vault.
1049
+ hasAmbiguousLinks: sqlHasAmbiguousLinks(
1050
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1051
+ tagScope.raw !== null,
1052
+ ),
963
1053
  path: parseQuery(url, "path") ?? undefined,
964
1054
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
965
1055
  excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
@@ -986,7 +1076,12 @@ export function parseNotesQueryOpts(url: URL): {
986
1076
  cursor: cursorParam ?? undefined,
987
1077
  };
988
1078
 
989
- return { queryOpts, hasSearch, hasNear, hasCursor };
1079
+ return {
1080
+ queryOpts: scopeQueryTags(queryOpts, tagScope.allowed, tagScope.raw),
1081
+ hasSearch,
1082
+ hasNear,
1083
+ hasCursor,
1084
+ };
990
1085
  }
991
1086
 
992
1087
  /**
@@ -1264,8 +1359,17 @@ async function handleNotesInner(
1264
1359
  // No tag-scope filtering needed (unlike include_links above): a
1265
1360
  // broken-link `target` never resolved to a note, so there's no
1266
1361
  // neighbor identity/content to leak — just the string this note's
1267
- // own [[wikilink]]/structured link already named.
1268
- result.broken_links = getUnresolvedLinksForNote(db, note.id);
1362
+ // own [[wikilink]]/structured link already named. The predicate is
1363
+ // not a filter but an ADDITION (vault#239): it makes a reference
1364
+ // whose only candidates are invisible read as broken here, instead
1365
+ // of only once the last of them is deleted.
1366
+ result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
1367
+ }
1368
+ if (parseBool(parseQuery(url, "include_ambiguous_links"), false)) {
1369
+ // Same no-tag-scope-needed reasoning as broken_links above: an
1370
+ // ambiguous `target` never became a link, so no neighbor identity
1371
+ // is exposed — only the string this note's own reference named.
1372
+ result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
1269
1373
  }
1270
1374
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
1271
1375
  result.attachments = await store.getAttachments(note.id);
@@ -1273,7 +1377,7 @@ async function handleNotesInner(
1273
1377
  // linkCount injected after filterMetadata on purpose — same as
1274
1378
  // links/attachments above; filterMetadata only touches `metadata`.
1275
1379
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1276
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
1380
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
1277
1381
  }
1278
1382
  return json(result);
1279
1383
  }
@@ -1338,7 +1442,7 @@ async function handleNotesInner(
1338
1442
  400,
1339
1443
  );
1340
1444
  }
1341
- const parsed = parseNotesQueryOpts(url);
1445
+ const parsed = parseNotesQueryOpts(url, tagScope);
1342
1446
  if (parsed.error) return parsed.error;
1343
1447
  try {
1344
1448
  const semanticResult = await store.semanticSearch(nearText, { ...parsed.queryOpts, cursor: undefined });
@@ -1348,7 +1452,26 @@ async function handleNotesInner(
1348
1452
  embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
1349
1453
  );
1350
1454
  }
1351
- const filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1455
+ let filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
1456
+ // Apply the same scoped presence filters as the structured list.
1457
+ filtered = narrowByVisibleAmbiguity(
1458
+ db,
1459
+ filtered,
1460
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1461
+ ambiguityVisibilityFor(db, tagScope),
1462
+ );
1463
+ filtered = narrowByVisibleBrokenness(
1464
+ db,
1465
+ filtered,
1466
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1467
+ ambiguityVisibilityFor(db, tagScope),
1468
+ );
1469
+ filtered = linkOps.narrowByVisibleLinks(
1470
+ db,
1471
+ filtered,
1472
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1473
+ ambiguityVisibilityFor(db, tagScope),
1474
+ );
1352
1475
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1353
1476
  const contentRange = parseContentRangeQuery(url, includeContent);
1354
1477
  if (contentRange.error) return contentRange.error;
@@ -1374,7 +1497,7 @@ async function handleNotesInner(
1374
1497
  output = output.map((n: any) => filterMetadata(n, inclMeta));
1375
1498
  }
1376
1499
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
1377
- const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
1500
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope));
1378
1501
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1379
1502
  }
1380
1503
  return jsonWithWarnings(output, semanticWarnings);
@@ -1456,7 +1579,7 @@ async function handleNotesInner(
1456
1579
  // were silently dropped — a well-formed result set answering a
1457
1580
  // different question. `search_mode` is still parsed here because it
1458
1581
  // is search-specific (the helper does not know about it).
1459
- const parsed = parseNotesQueryOpts(url);
1582
+ const parsed = parseNotesQueryOpts(url, tagScope);
1460
1583
  if (parsed.error) return parsed.error;
1461
1584
  const searchModeParsed = parseSearchModeParam(url);
1462
1585
  if (searchModeParsed.error) return searchModeParsed.error;
@@ -1543,7 +1666,26 @@ async function handleNotesInner(
1543
1666
  // Tag-scope: drop any result the token isn't permitted to see. Filter
1544
1667
  // happens after the store query so an empty post-filter list still
1545
1668
  // returns 200 [] (consistent with "no matches"), not 403.
1546
- const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1669
+ let results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
1670
+ // Apply the same scoped presence filters as the structured list.
1671
+ results = narrowByVisibleAmbiguity(
1672
+ db,
1673
+ results,
1674
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1675
+ ambiguityVisibilityFor(db, tagScope),
1676
+ );
1677
+ results = narrowByVisibleBrokenness(
1678
+ db,
1679
+ results,
1680
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1681
+ ambiguityVisibilityFor(db, tagScope),
1682
+ );
1683
+ results = linkOps.narrowByVisibleLinks(
1684
+ db,
1685
+ results,
1686
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1687
+ ambiguityVisibilityFor(db, tagScope),
1688
+ );
1547
1689
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1548
1690
  const contentRange = parseContentRangeQuery(url, includeContent);
1549
1691
  if (contentRange.error) return contentRange.error;
@@ -1577,6 +1719,7 @@ async function handleNotesInner(
1577
1719
  db,
1578
1720
  output.map((n: any) => n.id),
1579
1721
  parseLinkCountDirection(url),
1722
+ ambiguityVisibilityFor(db, tagScope),
1580
1723
  );
1581
1724
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1582
1725
  }
@@ -1608,7 +1751,7 @@ async function handleNotesInner(
1608
1751
  // Structured-query parsing is shared with the live `/subscribe` route
1609
1752
  // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
1610
1753
  // string to the same `QueryOpts` — predicate parity by construction.
1611
- const parsed = parseNotesQueryOpts(url);
1754
+ const parsed = parseNotesQueryOpts(url, tagScope);
1612
1755
  if (parsed.error) return parsed.error;
1613
1756
  const queryOpts = parsed.queryOpts!;
1614
1757
  const cursorParam = parseQuery(url, "cursor");
@@ -1685,7 +1828,31 @@ async function handleNotesInner(
1685
1828
  // path applies, THEN aggregate over just that visible id set
1686
1829
  // (reusing the `ids` filter `near` already pushes into SQL).
1687
1830
  const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
1688
- const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1831
+ let visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1832
+ // vault#581 auth review: a rollup over `has_ambiguous_links` is
1833
+ // the same oracle as the note-level filter — a non-zero count
1834
+ // still reveals the collision — so re-decide each note's
1835
+ // ambiguity on the visible sub-vault before aggregating.
1836
+ visible = narrowByVisibleAmbiguity(
1837
+ db,
1838
+ visible,
1839
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1840
+ ambiguityVisibilityFor(db, tagScope),
1841
+ );
1842
+ // vault#239: the `has_broken_links` rollup is the same oracle for
1843
+ // the same reason — re-decide brokenness on the sub-vault too.
1844
+ visible = narrowByVisibleBrokenness(
1845
+ db,
1846
+ visible,
1847
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1848
+ ambiguityVisibilityFor(db, tagScope),
1849
+ );
1850
+ visible = linkOps.narrowByVisibleLinks(
1851
+ db,
1852
+ visible,
1853
+ parseBoolOrUndef(parseQuery(url, "has_links")),
1854
+ ambiguityVisibilityFor(db, tagScope),
1855
+ );
1689
1856
  // Always run the rollup, even on an empty visible set: ungrouped
1690
1857
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
1691
1858
  rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
@@ -1839,12 +2006,39 @@ async function handleNotesInner(
1839
2006
  // output. Same semantics as the search path — empty result is 200 [],
1840
2007
  // not 403.
1841
2008
  results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
2009
+ // vault#581 auth review — `has_ambiguous_links` must answer on the
2010
+ // reader's OWN sub-vault: the SQL filter counts a persisted row even
2011
+ // when the notes that collided are invisible, which would let a scoped
2012
+ // token sweep its corpus for cross-scope naming collisions. No-op
2013
+ // unscoped and when the filter wasn't asked for. Mirrors the MCP door.
2014
+ results = narrowByVisibleAmbiguity(
2015
+ db,
2016
+ results,
2017
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
2018
+ ambiguityVisibilityFor(db, tagScope),
2019
+ );
2020
+ // vault#239 — same rule for `has_broken_links`: the SQL filter was
2021
+ // lifted for a scoped reader, so the real predicate is applied here on
2022
+ // that reader's own sub-vault. No-op unscoped. Mirrors the MCP door.
2023
+ results = narrowByVisibleBrokenness(
2024
+ db,
2025
+ results,
2026
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
2027
+ ambiguityVisibilityFor(db, tagScope),
2028
+ );
2029
+ results = linkOps.narrowByVisibleLinks(
2030
+ db,
2031
+ results,
2032
+ parseBoolOrUndef(parseQuery(url, "has_links")),
2033
+ ambiguityVisibilityFor(db, tagScope),
2034
+ );
1842
2035
 
1843
2036
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1844
2037
  const contentRange = parseContentRangeQuery(url, includeContent);
1845
2038
  if (contentRange.error) return contentRange.error;
1846
2039
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
1847
2040
  const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
2041
+ const includeAmbiguousLinks = parseBool(parseQuery(url, "include_ambiguous_links"), false);
1848
2042
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
1849
2043
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1850
2044
  const inclMeta = parseIncludeMetadata(url);
@@ -1917,6 +2111,7 @@ async function handleNotesInner(
1917
2111
  db,
1918
2112
  output.map((n: any) => n.id),
1919
2113
  parseLinkCountDirection(url),
2114
+ ambiguityVisibilityFor(db, tagScope),
1920
2115
  );
1921
2116
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1922
2117
  }
@@ -1945,7 +2140,7 @@ async function handleNotesInner(
1945
2140
  );
1946
2141
  }
1947
2142
 
1948
- if (includeLinks || includeBrokenLinks || includeAttachments) {
2143
+ if (includeLinks || includeBrokenLinks || includeAmbiguousLinks || includeAttachments) {
1949
2144
  // Whole-page link hydration in a constant number of queries — the
1950
2145
  // per-note variant cost (1 link query + 1 summary query + N tag
1951
2146
  // queries) × page size. 2026-06-10 perf measurements.
@@ -1956,7 +2151,11 @@ async function handleNotesInner(
1956
2151
  // filtering needed — a broken-link `target` never resolved to a
1957
2152
  // note, so there's no neighbor identity to leak.
1958
2153
  const brokenLinksByNote = includeBrokenLinks
1959
- ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
2154
+ ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
2155
+ : null;
2156
+ // Same again for the ambiguity twin (vault#581), same non-leak logic.
2157
+ const ambiguousLinksByNote = includeAmbiguousLinks
2158
+ ? getAmbiguousLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
1960
2159
  : null;
1961
2160
  const enrichedOut: any[] = [];
1962
2161
  for (const n of output) {
@@ -1970,6 +2169,7 @@ async function handleNotesInner(
1970
2169
  );
1971
2170
  }
1972
2171
  if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
2172
+ if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
1973
2173
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
1974
2174
  enrichedOut.push(enriched);
1975
2175
  }
@@ -2282,20 +2482,9 @@ async function handleNotesInner(
2282
2482
  if (outcome.status === "resolved") {
2283
2483
  await store.createLink(sourceId, outcome.note_id, link.relationship);
2284
2484
  } else if (outcome.status === "ambiguous") {
2285
- pushLinkWarning(sourceId, {
2286
- code: "ambiguous_link",
2287
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2288
- target: link.target,
2289
- relationship: link.relationship,
2290
- candidate_count: outcome.candidates.length,
2291
- });
2485
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2292
2486
  } else {
2293
- pushLinkWarning(sourceId, {
2294
- code: "unresolved_link",
2295
- 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.`,
2296
- target: link.target,
2297
- relationship: link.relationship,
2298
- });
2487
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
2299
2488
  }
2300
2489
  }
2301
2490
  }
@@ -2389,10 +2578,10 @@ async function handleNotesInner(
2389
2578
  // default "error" path's response shape is untouched.
2390
2579
  const final = refreshed.map((n) => {
2391
2580
  const validated = attachValidationStatus(store, db, n);
2392
- const warnings = linkWarningsByNote.get(n.id);
2581
+ const warnings = narrowWriteWarnings(db, linkWarningsByNote.get(n.id) ?? [], tagScope);
2393
2582
  const existed = existedMap.get(n.id);
2394
2583
  let out: any = validated;
2395
- if (warnings && warnings.length > 0) out = { ...out, warnings };
2584
+ if (warnings.length > 0) out = { ...out, warnings };
2396
2585
  if (existed !== undefined) out = { ...out, existed };
2397
2586
  // Tag-scope (vault#568): a create response echoes the STORED note,
2398
2587
  // and under `if_exists: ignore|update|replace` that's a note that
@@ -2690,7 +2879,7 @@ async function handleNotesInner(
2690
2879
  result.attachments = await store.getAttachments(note.id);
2691
2880
  }
2692
2881
  if (parseBool(parseQuery(url, "include_link_count"), false)) {
2693
- result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
2882
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
2694
2883
  }
2695
2884
  return json(result);
2696
2885
  }
@@ -2780,20 +2969,9 @@ async function handleNotesInner(
2780
2969
  if (outcome.status === "resolved") {
2781
2970
  await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
2782
2971
  } else if (outcome.status === "ambiguous") {
2783
- createWarnings.push({
2784
- code: "ambiguous_link",
2785
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2786
- target: link.target,
2787
- relationship: link.relationship,
2788
- candidate_count: outcome.candidates.length,
2789
- });
2972
+ createWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2790
2973
  } else {
2791
- createWarnings.push({
2792
- code: "unresolved_link",
2793
- 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.`,
2794
- target: link.target,
2795
- relationship: link.relationship,
2796
- });
2974
+ createWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
2797
2975
  }
2798
2976
  }
2799
2977
  }
@@ -2804,10 +2982,11 @@ async function handleNotesInner(
2804
2982
  if (content) {
2805
2983
  createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
2806
2984
  }
2985
+ const scopedCreateWarnings = narrowWriteWarnings(db, createWarnings, tagScope);
2807
2986
  const final = await store.getNote(created.id);
2808
2987
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
2809
2988
  let validated: any = attachValidationStatus(store, db, final);
2810
- if (createWarnings.length > 0) validated.warnings = createWarnings;
2989
+ if (scopedCreateWarnings.length > 0) validated.warnings = scopedCreateWarnings;
2811
2990
  // Tag-scope (vault#568): scrub the echoed note so create-then-read
2812
2991
  // returns the SAME shape. A scoped token may legitimately attach an
2813
2992
  // out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
@@ -2825,7 +3004,7 @@ async function handleNotesInner(
2825
3004
  const lean: any = toNoteIndex(validated);
2826
3005
  const vs = (validated as any).validation_status;
2827
3006
  if (vs !== undefined) lean.validation_status = vs;
2828
- if (createWarnings.length > 0) lean.warnings = createWarnings;
3007
+ if (scopedCreateWarnings.length > 0) lean.warnings = scopedCreateWarnings;
2829
3008
  lean.created = true;
2830
3009
  return json(lean);
2831
3010
  }
@@ -3104,20 +3283,9 @@ async function handleNotesInner(
3104
3283
  if (outcome.status === "resolved") {
3105
3284
  await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
3106
3285
  } else if (outcome.status === "ambiguous") {
3107
- linkWarnings.push({
3108
- code: "ambiguous_link",
3109
- message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
3110
- target: link.target,
3111
- relationship: link.relationship,
3112
- candidate_count: outcome.candidates.length,
3113
- });
3286
+ linkWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
3114
3287
  } else {
3115
- linkWarnings.push({
3116
- code: "unresolved_link",
3117
- 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.`,
3118
- target: link.target,
3119
- relationship: link.relationship,
3120
- });
3288
+ linkWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
3121
3289
  }
3122
3290
  }
3123
3291
  }
@@ -3171,7 +3339,8 @@ async function handleNotesInner(
3171
3339
  tagScope.raw,
3172
3340
  );
3173
3341
  }
3174
- if (linkWarnings.length > 0) validated.warnings = linkWarnings;
3342
+ const scopedLinkWarnings = narrowWriteWarnings(db, linkWarnings, tagScope);
3343
+ if (scopedLinkWarnings.length > 0) validated.warnings = scopedLinkWarnings;
3175
3344
  const includeContentResp = body.include_content !== false;
3176
3345
  // `created: false` is appended to every update-path response so
3177
3346
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -3184,7 +3353,7 @@ async function handleNotesInner(
3184
3353
  // Carry the link echo across the lean conversion — `toNoteIndex`
3185
3354
  // drops unknown fields, same as the `validation_status` recipe above.
3186
3355
  if (validated.links !== undefined) lean.links = validated.links;
3187
- if (linkWarnings.length > 0) lean.warnings = linkWarnings;
3356
+ if (scopedLinkWarnings.length > 0) lean.warnings = scopedLinkWarnings;
3188
3357
  lean.created = false;
3189
3358
  return json(lean);
3190
3359
  } catch (e: any) {
@@ -3924,7 +4093,7 @@ export async function handleFindPath(
3924
4093
 
3925
4094
  type VaultConfigLike = {
3926
4095
  name: string;
3927
- description?: string;
4096
+ description?: string | null;
3928
4097
  audio_retention?: "keep" | "until_transcribed" | "never";
3929
4098
  auto_transcribe?: { enabled?: boolean };
3930
4099
  };
@@ -4024,12 +4193,33 @@ export async function handleVault(
4024
4193
  const parsedBody = await parseJsonBody(req);
4025
4194
  if (!parsedBody.ok) return parsedBody.response;
4026
4195
  const body = parsedBody.body as {
4027
- description?: string;
4196
+ description?: unknown;
4028
4197
  config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
4029
4198
  };
4030
4199
  let dirty = false;
4031
4200
 
4032
4201
  if (body.description !== undefined) {
4202
+ // vault#669: runtime type guard mirroring the audio_retention /
4203
+ // auto_transcribe validators below — the `description?: string`
4204
+ // annotation was a compile-time claim guarding nothing (cast retyped
4205
+ // to `unknown` so the check is mandatory), so a write/admin caller
4206
+ // could persist a non-string and poison the next MCP `initialize`
4207
+ // (cloud#87). Reject non-string/non-null with the same 400 body
4208
+ // shape the sibling validators in this handler emit. `null` stays
4209
+ // legal: it clears the description. Cross-door parity with cloud#263.
4210
+ if (body.description !== null && typeof body.description !== "string") {
4211
+ return json(
4212
+ {
4213
+ error: "invalid_description",
4214
+ error_type: "invalid_description",
4215
+ field: "description",
4216
+ got: body.description,
4217
+ message: "description must be a string or null",
4218
+ hint: "pass a string, or null to clear the description",
4219
+ },
4220
+ 400,
4221
+ );
4222
+ }
4033
4223
  vaultConfig.description = body.description;
4034
4224
  dirty = true;
4035
4225
  }
@@ -4111,11 +4301,35 @@ export function handleUnresolvedWikilinks(
4111
4301
  // and the wikilink target strings those notes contain. Filter the page and
4112
4302
  // recompute `count` from the filtered set so the aggregate total of
4113
4303
  // out-of-scope rows doesn't leak either.
4114
- const filtered = result.unresolved.filter((row) => {
4304
+ const inScope = (row: { source_id: string }): boolean => {
4115
4305
  const note = getNote(db, row.source_id);
4116
4306
  return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
4307
+ };
4308
+ const filtered = result.unresolved.filter(inScope);
4309
+
4310
+ // vault#239: a reference whose only candidates are OUT OF SCOPE is broken
4311
+ // in this reader's sub-vault, but its row lives in `ambiguous_wikilinks`
4312
+ // until the last of those candidates is deleted. Reporting it only after
4313
+ // that deletion makes this listing a timing oracle for a cross-scope naming
4314
+ // collision — the same one `getAmbiguousLinksForNotes` narrows away on the
4315
+ // note surfaces. Fold those rows in so the answer is stable across the
4316
+ // delete. Deduped against the base listing by (source, relationship,
4317
+ // target); the two tables are disjoint by construction, so this only
4318
+ // guards against a stale row.
4319
+ const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
4320
+ const visible = ambiguityVisibilityFor(db, tagScope)!;
4321
+ const collapsed = [
4322
+ ...listZeroVisibleAmbiguousAsUnresolved(db, visible, limit),
4323
+ ...listResolvedToInvisibleAsUnresolved(db, visible, limit),
4324
+ ].filter((row) => {
4325
+ const key = `${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`;
4326
+ if (!inScope(row) || seen.has(key)) return false;
4327
+ seen.add(key);
4328
+ return true;
4117
4329
  });
4118
- return Response.json({ unresolved: filtered, count: filtered.length });
4330
+
4331
+ const merged = [...filtered, ...collapsed].slice(0, limit);
4332
+ return Response.json({ unresolved: merged, count: merged.length });
4119
4333
  }
4120
4334
 
4121
4335
  // ---------------------------------------------------------------------------
@@ -1880,6 +1880,73 @@ describe("scope enforcement on /api/*", () => {
1880
1880
  expect(res.status).toBe(401);
1881
1881
  });
1882
1882
 
1883
+ // ----- vault#669: PATCH /api/vault description type guard (cloud#87) ------
1884
+ //
1885
+ // The REST door's `description` write had no runtime type check — the
1886
+ // `description?: string` body annotation was a compile-time claim guarding
1887
+ // nothing, so a write/admin caller could persist a non-string and poison
1888
+ // the next MCP `initialize` (serverInstruction's `description.trim()`).
1889
+ // The guard rejects non-string/non-null with the same 400 shape the
1890
+ // sibling audio_retention / auto_transcribe validators emit. `null` still
1891
+ // clears the description.
1892
+ describe("PATCH /api/vault description type guard (vault#669)", () => {
1893
+ async function patchDescription(vault: string, token: string, description: unknown): Promise<Response> {
1894
+ const path = `/vault/${vault}/api/vault`;
1895
+ return route(
1896
+ new Request(`http://localhost:1940${path}`, {
1897
+ method: "PATCH",
1898
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1899
+ body: JSON.stringify({ description }),
1900
+ }),
1901
+ path,
1902
+ );
1903
+ }
1904
+
1905
+ for (const [label, value] of [
1906
+ ["a number", 123],
1907
+ ["an object", { text: "nope" }],
1908
+ ["an array", ["a", "b"]],
1909
+ ["a boolean", true],
1910
+ ] as const) {
1911
+ test(`${label} description → 400 invalid_description, description unchanged`, async () => {
1912
+ createVault("journal", "original");
1913
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
1914
+
1915
+ const res = await patchDescription("journal", token, value);
1916
+ expect(res.status).toBe(400);
1917
+ const body = (await res.json()) as { error_type?: string; field?: string };
1918
+ expect(body.error_type).toBe("invalid_description");
1919
+ expect(body.field).toBe("description");
1920
+
1921
+ // Nothing persisted — no poison state introduced.
1922
+ const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
1923
+ expect(((await getRes.json()) as { description?: unknown }).description).toBe("original");
1924
+ });
1925
+ }
1926
+
1927
+ test("null description → 200, clears it", async () => {
1928
+ createVault("journal", "to be cleared");
1929
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
1930
+
1931
+ const res = await patchDescription("journal", token, null);
1932
+ expect(res.status).toBe(200);
1933
+
1934
+ const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
1935
+ expect(((await getRes.json()) as { description?: unknown }).description).toBeNull();
1936
+ });
1937
+
1938
+ test("a valid string description → 200, persists un-mangled", async () => {
1939
+ createVault("journal", "before");
1940
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
1941
+
1942
+ const res = await patchDescription("journal", token, "after the edit");
1943
+ expect(res.status).toBe(200);
1944
+
1945
+ const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
1946
+ expect(((await getRes.json()) as { description?: unknown }).description).toBe("after the edit");
1947
+ });
1948
+ });
1949
+
1883
1950
  // ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
1884
1951
 
1885
1952
  /**