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

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
@@ -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,17 @@ import {
30
31
  resolveStructuredLinkNote,
31
32
  getUnresolvedLinksForNote,
32
33
  getUnresolvedLinksForNotes,
34
+ listZeroVisibleAmbiguousAsUnresolved,
35
+ narrowByVisibleBrokenness,
36
+ sqlHasBrokenLinks,
37
+ getAmbiguousLinksForNote,
38
+ getAmbiguousLinksForNotes,
39
+ narrowByVisibleAmbiguity,
40
+ sqlHasAmbiguousLinks,
33
41
  getContentWikilinkWarnings,
42
+ ambiguousLinkWarning,
43
+ unresolvedLinkWarning,
44
+ narrowLinkWarningsForVisibility,
34
45
  } from "../core/src/wikilinks.ts";
35
46
  import { transactionAsync } from "../core/src/txn.ts";
36
47
  import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
@@ -67,6 +78,7 @@ import {
67
78
  filterHydratedLinksByTagScope,
68
79
  filterNotesByTagScope,
69
80
  noteWithinTagScope,
81
+ scopeQueryTags,
70
82
  scrubIndexedFieldConflictError,
71
83
  scrubNotesTagsByScope,
72
84
  scrubNoteTagsByScope,
@@ -901,8 +913,66 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
901
913
  * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
902
914
  * as the inline code did. `hasSearch` is surfaced from the raw `search` param
903
915
  * (this helper does not itself build a search query — the caller routes that).
916
+ *
917
+ * `tagScope` (vault#675): out-of-scope `tag=` / `exclude_tag=` inputs are
918
+ * rewritten so they match nothing, exactly as a nonexistent tag would — see
919
+ * `scopeQueryTags`. Applied here so every branch that lowers a query string
920
+ * (structured, `search`, `semantic`, `aggregate`, `near`, cursor) is covered
921
+ * by one call, the same way this helper already gives subscribe/search
922
+ * predicate parity. Default `NO_TAG_SCOPE` → no-op, so the WS route's
923
+ * upgrade-time parse (which runs before the socket authenticates) is
924
+ * unchanged; `ws-server.ts` applies the scoping once auth is known.
925
+ */
926
+ /**
927
+ * The `visible` predicate the vault#581 ambiguous-links surface consults for
928
+ * a TAG-SCOPED reader — `undefined` (and therefore a complete no-op) for an
929
+ * unscoped one. Identical shape and rule to the MCP door's `ambiguityVisible`
930
+ * (`src/mcp-tools.ts`) and to `nearTraversable`: look a CANDIDATE note's tags
931
+ * up by id and apply the same `noteWithinTagScope` test every other read path
932
+ * uses, so the two doors cannot drift on who counts as a visible candidate.
933
+ *
934
+ * Needed because `candidate_count` is derived vault-wide: without it a
935
+ * `work`-scoped reader learns from `candidate_count: 2` on its own `[[Dup]]`
936
+ * that a second `Dup` exists in a scope it cannot see.
937
+ *
938
+ * vault#239 reuses the SAME predicate for the BROKEN-links surface — "which
939
+ * of this target's candidates can the reader see" is one question, and the
940
+ * ambiguous (>=2 visible) and broken (0 visible) answers are two faces of it.
941
+ */
942
+ function ambiguityVisibilityFor(
943
+ db: Database,
944
+ tagScope: TagScopeCtx,
945
+ ): ((noteId: string) => boolean) | undefined {
946
+ if (tagScope.raw === null) return undefined;
947
+ return (noteId: string) =>
948
+ noteWithinTagScope(
949
+ { id: noteId, tags: getNoteTags(db, noteId) } as Note,
950
+ tagScope.allowed,
951
+ tagScope.raw,
952
+ );
953
+ }
954
+
955
+ /**
956
+ * Re-decide a WRITE response's `ambiguous_link` warnings against the caller's
957
+ * own sub-vault. `create-note`/`update-note` echo per-note `warnings`, and an
958
+ * `ambiguous_link` entry carries a `candidate_count` derived VAULT-WIDE — so
959
+ * without this a `work`-scoped writer saving `[[Dup]]` learns from
960
+ * `candidate_count: 2` that a second `Dup` exists in a scope it cannot see.
961
+ * That is exactly the vault#707 read-side oracle, reachable through the write
962
+ * door. Same predicate ({@link ambiguityVisibilityFor}) and same core decision
963
+ * function as the read surfaces, so the doors cannot drift. Unscoped: the
964
+ * predicate is `undefined` and the array is returned unchanged.
904
965
  */
905
- export function parseNotesQueryOpts(url: URL): {
966
+ function narrowWriteWarnings(
967
+ db: Database,
968
+ warnings: QueryWarning[],
969
+ tagScope: TagScopeCtx,
970
+ ): QueryWarning[] {
971
+ const visible = ambiguityVisibilityFor(db, tagScope);
972
+ return visible ? narrowLinkWarningsForVisibility(db, warnings, visible) : warnings;
973
+ }
974
+
975
+ export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCOPE): {
906
976
  queryOpts?: QueryOpts;
907
977
  hasSearch: boolean;
908
978
  hasNear: boolean;
@@ -959,7 +1029,26 @@ export function parseNotesQueryOpts(url: URL): {
959
1029
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
960
1030
  // Presence filter on dangling outbound wikilinks/structured links
961
1031
  // (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
962
- hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1032
+ // vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
1033
+ // into SQL — a note can be broken in that reader's sub-vault while
1034
+ // carrying no `unresolved_wikilinks` row at all (its target's only
1035
+ // candidates are invisible, so the row sits in `ambiguous_wikilinks`
1036
+ // instead). Both are lifted here and re-decided per note by
1037
+ // `narrowByVisibleBrokenness`. Identity for an unscoped reader.
1038
+ hasBrokenLinks: sqlHasBrokenLinks(
1039
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1040
+ tagScope.raw !== null,
1041
+ ),
1042
+ // Presence filter on AMBIGUOUS outbound links — a target that matched ≥2
1043
+ // notes (vault#581) — see core/src/types.ts QueryOpts.hasAmbiguousLinks.
1044
+ // vault#581 auth review: for a TAG-SCOPED reader only the `true` polarity
1045
+ // is safe to push into SQL (it's a superset of the right answer);
1046
+ // `false` is lifted here and both are re-decided per note by
1047
+ // `narrowByVisibleAmbiguity` on the reader's own visible sub-vault.
1048
+ hasAmbiguousLinks: sqlHasAmbiguousLinks(
1049
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1050
+ tagScope.raw !== null,
1051
+ ),
963
1052
  path: parseQuery(url, "path") ?? undefined,
964
1053
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
965
1054
  excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
@@ -986,7 +1075,12 @@ export function parseNotesQueryOpts(url: URL): {
986
1075
  cursor: cursorParam ?? undefined,
987
1076
  };
988
1077
 
989
- return { queryOpts, hasSearch, hasNear, hasCursor };
1078
+ return {
1079
+ queryOpts: scopeQueryTags(queryOpts, tagScope.allowed, tagScope.raw),
1080
+ hasSearch,
1081
+ hasNear,
1082
+ hasCursor,
1083
+ };
990
1084
  }
991
1085
 
992
1086
  /**
@@ -1264,8 +1358,17 @@ async function handleNotesInner(
1264
1358
  // No tag-scope filtering needed (unlike include_links above): a
1265
1359
  // broken-link `target` never resolved to a note, so there's no
1266
1360
  // 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);
1361
+ // own [[wikilink]]/structured link already named. The predicate is
1362
+ // not a filter but an ADDITION (vault#239): it makes a reference
1363
+ // whose only candidates are invisible read as broken here, instead
1364
+ // of only once the last of them is deleted.
1365
+ result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
1366
+ }
1367
+ if (parseBool(parseQuery(url, "include_ambiguous_links"), false)) {
1368
+ // Same no-tag-scope-needed reasoning as broken_links above: an
1369
+ // ambiguous `target` never became a link, so no neighbor identity
1370
+ // is exposed — only the string this note's own reference named.
1371
+ result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
1269
1372
  }
1270
1373
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
1271
1374
  result.attachments = await store.getAttachments(note.id);
@@ -1338,7 +1441,7 @@ async function handleNotesInner(
1338
1441
  400,
1339
1442
  );
1340
1443
  }
1341
- const parsed = parseNotesQueryOpts(url);
1444
+ const parsed = parseNotesQueryOpts(url, tagScope);
1342
1445
  if (parsed.error) return parsed.error;
1343
1446
  try {
1344
1447
  const semanticResult = await store.semanticSearch(nearText, { ...parsed.queryOpts, cursor: undefined });
@@ -1456,7 +1559,7 @@ async function handleNotesInner(
1456
1559
  // were silently dropped — a well-formed result set answering a
1457
1560
  // different question. `search_mode` is still parsed here because it
1458
1561
  // is search-specific (the helper does not know about it).
1459
- const parsed = parseNotesQueryOpts(url);
1562
+ const parsed = parseNotesQueryOpts(url, tagScope);
1460
1563
  if (parsed.error) return parsed.error;
1461
1564
  const searchModeParsed = parseSearchModeParam(url);
1462
1565
  if (searchModeParsed.error) return searchModeParsed.error;
@@ -1608,7 +1711,7 @@ async function handleNotesInner(
1608
1711
  // Structured-query parsing is shared with the live `/subscribe` route
1609
1712
  // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
1610
1713
  // string to the same `QueryOpts` — predicate parity by construction.
1611
- const parsed = parseNotesQueryOpts(url);
1714
+ const parsed = parseNotesQueryOpts(url, tagScope);
1612
1715
  if (parsed.error) return parsed.error;
1613
1716
  const queryOpts = parsed.queryOpts!;
1614
1717
  const cursorParam = parseQuery(url, "cursor");
@@ -1685,7 +1788,25 @@ async function handleNotesInner(
1685
1788
  // path applies, THEN aggregate over just that visible id set
1686
1789
  // (reusing the `ids` filter `near` already pushes into SQL).
1687
1790
  const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
1688
- const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1791
+ let visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1792
+ // vault#581 auth review: a rollup over `has_ambiguous_links` is
1793
+ // the same oracle as the note-level filter — a non-zero count
1794
+ // still reveals the collision — so re-decide each note's
1795
+ // ambiguity on the visible sub-vault before aggregating.
1796
+ visible = narrowByVisibleAmbiguity(
1797
+ db,
1798
+ visible,
1799
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1800
+ ambiguityVisibilityFor(db, tagScope),
1801
+ );
1802
+ // vault#239: the `has_broken_links` rollup is the same oracle for
1803
+ // the same reason — re-decide brokenness on the sub-vault too.
1804
+ visible = narrowByVisibleBrokenness(
1805
+ db,
1806
+ visible,
1807
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1808
+ ambiguityVisibilityFor(db, tagScope),
1809
+ );
1689
1810
  // Always run the rollup, even on an empty visible set: ungrouped
1690
1811
  // count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
1691
1812
  rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
@@ -1839,12 +1960,33 @@ async function handleNotesInner(
1839
1960
  // output. Same semantics as the search path — empty result is 200 [],
1840
1961
  // not 403.
1841
1962
  results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
1963
+ // vault#581 auth review — `has_ambiguous_links` must answer on the
1964
+ // reader's OWN sub-vault: the SQL filter counts a persisted row even
1965
+ // when the notes that collided are invisible, which would let a scoped
1966
+ // token sweep its corpus for cross-scope naming collisions. No-op
1967
+ // unscoped and when the filter wasn't asked for. Mirrors the MCP door.
1968
+ results = narrowByVisibleAmbiguity(
1969
+ db,
1970
+ results,
1971
+ parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
1972
+ ambiguityVisibilityFor(db, tagScope),
1973
+ );
1974
+ // vault#239 — same rule for `has_broken_links`: the SQL filter was
1975
+ // lifted for a scoped reader, so the real predicate is applied here on
1976
+ // that reader's own sub-vault. No-op unscoped. Mirrors the MCP door.
1977
+ results = narrowByVisibleBrokenness(
1978
+ db,
1979
+ results,
1980
+ parseBoolOrUndef(parseQuery(url, "has_broken_links")),
1981
+ ambiguityVisibilityFor(db, tagScope),
1982
+ );
1842
1983
 
1843
1984
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
1844
1985
  const contentRange = parseContentRangeQuery(url, includeContent);
1845
1986
  if (contentRange.error) return contentRange.error;
1846
1987
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
1847
1988
  const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
1989
+ const includeAmbiguousLinks = parseBool(parseQuery(url, "include_ambiguous_links"), false);
1848
1990
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
1849
1991
  const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
1850
1992
  const inclMeta = parseIncludeMetadata(url);
@@ -1945,7 +2087,7 @@ async function handleNotesInner(
1945
2087
  );
1946
2088
  }
1947
2089
 
1948
- if (includeLinks || includeBrokenLinks || includeAttachments) {
2090
+ if (includeLinks || includeBrokenLinks || includeAmbiguousLinks || includeAttachments) {
1949
2091
  // Whole-page link hydration in a constant number of queries — the
1950
2092
  // per-note variant cost (1 link query + 1 summary query + N tag
1951
2093
  // queries) × page size. 2026-06-10 perf measurements.
@@ -1956,7 +2098,11 @@ async function handleNotesInner(
1956
2098
  // filtering needed — a broken-link `target` never resolved to a
1957
2099
  // note, so there's no neighbor identity to leak.
1958
2100
  const brokenLinksByNote = includeBrokenLinks
1959
- ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
2101
+ ? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
2102
+ : null;
2103
+ // Same again for the ambiguity twin (vault#581), same non-leak logic.
2104
+ const ambiguousLinksByNote = includeAmbiguousLinks
2105
+ ? getAmbiguousLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
1960
2106
  : null;
1961
2107
  const enrichedOut: any[] = [];
1962
2108
  for (const n of output) {
@@ -1970,6 +2116,7 @@ async function handleNotesInner(
1970
2116
  );
1971
2117
  }
1972
2118
  if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
2119
+ if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
1973
2120
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
1974
2121
  enrichedOut.push(enriched);
1975
2122
  }
@@ -2282,20 +2429,9 @@ async function handleNotesInner(
2282
2429
  if (outcome.status === "resolved") {
2283
2430
  await store.createLink(sourceId, outcome.note_id, link.relationship);
2284
2431
  } 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
- });
2432
+ pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2292
2433
  } 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
- });
2434
+ pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
2299
2435
  }
2300
2436
  }
2301
2437
  }
@@ -2389,10 +2525,10 @@ async function handleNotesInner(
2389
2525
  // default "error" path's response shape is untouched.
2390
2526
  const final = refreshed.map((n) => {
2391
2527
  const validated = attachValidationStatus(store, db, n);
2392
- const warnings = linkWarningsByNote.get(n.id);
2528
+ const warnings = narrowWriteWarnings(db, linkWarningsByNote.get(n.id) ?? [], tagScope);
2393
2529
  const existed = existedMap.get(n.id);
2394
2530
  let out: any = validated;
2395
- if (warnings && warnings.length > 0) out = { ...out, warnings };
2531
+ if (warnings.length > 0) out = { ...out, warnings };
2396
2532
  if (existed !== undefined) out = { ...out, existed };
2397
2533
  // Tag-scope (vault#568): a create response echoes the STORED note,
2398
2534
  // and under `if_exists: ignore|update|replace` that's a note that
@@ -2780,20 +2916,9 @@ async function handleNotesInner(
2780
2916
  if (outcome.status === "resolved") {
2781
2917
  await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
2782
2918
  } 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
- });
2919
+ createWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
2790
2920
  } 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
- });
2921
+ createWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
2797
2922
  }
2798
2923
  }
2799
2924
  }
@@ -2804,10 +2929,11 @@ async function handleNotesInner(
2804
2929
  if (content) {
2805
2930
  createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
2806
2931
  }
2932
+ const scopedCreateWarnings = narrowWriteWarnings(db, createWarnings, tagScope);
2807
2933
  const final = await store.getNote(created.id);
2808
2934
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
2809
2935
  let validated: any = attachValidationStatus(store, db, final);
2810
- if (createWarnings.length > 0) validated.warnings = createWarnings;
2936
+ if (scopedCreateWarnings.length > 0) validated.warnings = scopedCreateWarnings;
2811
2937
  // Tag-scope (vault#568): scrub the echoed note so create-then-read
2812
2938
  // returns the SAME shape. A scoped token may legitimately attach an
2813
2939
  // out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
@@ -2825,7 +2951,7 @@ async function handleNotesInner(
2825
2951
  const lean: any = toNoteIndex(validated);
2826
2952
  const vs = (validated as any).validation_status;
2827
2953
  if (vs !== undefined) lean.validation_status = vs;
2828
- if (createWarnings.length > 0) lean.warnings = createWarnings;
2954
+ if (scopedCreateWarnings.length > 0) lean.warnings = scopedCreateWarnings;
2829
2955
  lean.created = true;
2830
2956
  return json(lean);
2831
2957
  }
@@ -3104,20 +3230,9 @@ async function handleNotesInner(
3104
3230
  if (outcome.status === "resolved") {
3105
3231
  await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
3106
3232
  } 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
- });
3233
+ linkWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
3114
3234
  } 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
- });
3235
+ linkWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
3121
3236
  }
3122
3237
  }
3123
3238
  }
@@ -3171,7 +3286,8 @@ async function handleNotesInner(
3171
3286
  tagScope.raw,
3172
3287
  );
3173
3288
  }
3174
- if (linkWarnings.length > 0) validated.warnings = linkWarnings;
3289
+ const scopedLinkWarnings = narrowWriteWarnings(db, linkWarnings, tagScope);
3290
+ if (scopedLinkWarnings.length > 0) validated.warnings = scopedLinkWarnings;
3175
3291
  const includeContentResp = body.include_content !== false;
3176
3292
  // `created: false` is appended to every update-path response so
3177
3293
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -3184,7 +3300,7 @@ async function handleNotesInner(
3184
3300
  // Carry the link echo across the lean conversion — `toNoteIndex`
3185
3301
  // drops unknown fields, same as the `validation_status` recipe above.
3186
3302
  if (validated.links !== undefined) lean.links = validated.links;
3187
- if (linkWarnings.length > 0) lean.warnings = linkWarnings;
3303
+ if (scopedLinkWarnings.length > 0) lean.warnings = scopedLinkWarnings;
3188
3304
  lean.created = false;
3189
3305
  return json(lean);
3190
3306
  } catch (e: any) {
@@ -3924,7 +4040,7 @@ export async function handleFindPath(
3924
4040
 
3925
4041
  type VaultConfigLike = {
3926
4042
  name: string;
3927
- description?: string;
4043
+ description?: string | null;
3928
4044
  audio_retention?: "keep" | "until_transcribed" | "never";
3929
4045
  auto_transcribe?: { enabled?: boolean };
3930
4046
  };
@@ -4024,12 +4140,33 @@ export async function handleVault(
4024
4140
  const parsedBody = await parseJsonBody(req);
4025
4141
  if (!parsedBody.ok) return parsedBody.response;
4026
4142
  const body = parsedBody.body as {
4027
- description?: string;
4143
+ description?: unknown;
4028
4144
  config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
4029
4145
  };
4030
4146
  let dirty = false;
4031
4147
 
4032
4148
  if (body.description !== undefined) {
4149
+ // vault#669: runtime type guard mirroring the audio_retention /
4150
+ // auto_transcribe validators below — the `description?: string`
4151
+ // annotation was a compile-time claim guarding nothing (cast retyped
4152
+ // to `unknown` so the check is mandatory), so a write/admin caller
4153
+ // could persist a non-string and poison the next MCP `initialize`
4154
+ // (cloud#87). Reject non-string/non-null with the same 400 body
4155
+ // shape the sibling validators in this handler emit. `null` stays
4156
+ // legal: it clears the description. Cross-door parity with cloud#263.
4157
+ if (body.description !== null && typeof body.description !== "string") {
4158
+ return json(
4159
+ {
4160
+ error: "invalid_description",
4161
+ error_type: "invalid_description",
4162
+ field: "description",
4163
+ got: body.description,
4164
+ message: "description must be a string or null",
4165
+ hint: "pass a string, or null to clear the description",
4166
+ },
4167
+ 400,
4168
+ );
4169
+ }
4033
4170
  vaultConfig.description = body.description;
4034
4171
  dirty = true;
4035
4172
  }
@@ -4111,11 +4248,28 @@ export function handleUnresolvedWikilinks(
4111
4248
  // and the wikilink target strings those notes contain. Filter the page and
4112
4249
  // recompute `count` from the filtered set so the aggregate total of
4113
4250
  // out-of-scope rows doesn't leak either.
4114
- const filtered = result.unresolved.filter((row) => {
4251
+ const inScope = (row: { source_id: string }): boolean => {
4115
4252
  const note = getNote(db, row.source_id);
4116
4253
  return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
4117
- });
4118
- return Response.json({ unresolved: filtered, count: filtered.length });
4254
+ };
4255
+ const filtered = result.unresolved.filter(inScope);
4256
+
4257
+ // vault#239: a reference whose only candidates are OUT OF SCOPE is broken
4258
+ // in this reader's sub-vault, but its row lives in `ambiguous_wikilinks`
4259
+ // until the last of those candidates is deleted. Reporting it only after
4260
+ // that deletion makes this listing a timing oracle for a cross-scope naming
4261
+ // collision — the same one `getAmbiguousLinksForNotes` narrows away on the
4262
+ // note surfaces. Fold those rows in so the answer is stable across the
4263
+ // delete. Deduped against the base listing by (source, relationship,
4264
+ // target); the two tables are disjoint by construction, so this only
4265
+ // guards against a stale row.
4266
+ const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
4267
+ const collapsed = listZeroVisibleAmbiguousAsUnresolved(db, ambiguityVisibilityFor(db, tagScope)!, limit)
4268
+ .filter((row) => inScope(row)
4269
+ && !seen.has(`${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`));
4270
+
4271
+ const merged = [...filtered, ...collapsed].slice(0, limit);
4272
+ return Response.json({ unresolved: merged, count: merged.length });
4119
4273
  }
4120
4274
 
4121
4275
  // ---------------------------------------------------------------------------
@@ -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
  /**