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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -13,9 +13,16 @@
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
- import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
16
+ import {
17
+ collectUnknownTagWarnings,
18
+ emptySearchWarning,
19
+ ignoredParamWarning,
20
+ computeSearchDidYouMean,
21
+ searchDidYouMeanWarning,
22
+ type QueryWarning,
23
+ } from "../core/src/query-warnings.ts";
17
24
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
18
- import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
25
+ import { listUnresolvedWikilinks, resolveOrQueueLink, resolveStructuredLinkNote } from "../core/src/wikilinks.ts";
19
26
  import { transactionAsync } from "../core/src/txn.ts";
20
27
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
21
28
  import {
@@ -45,10 +52,11 @@ import {
45
52
  scrubParentCycleError,
46
53
  scrubReferencingTagsByScope,
47
54
  scrubTagFieldViolationsByScope,
55
+ scrubValidationStatusByScope,
48
56
  tagScopeForbidden,
49
57
  tagsWithinScope,
50
58
  } from "./tag-scope.ts";
51
- import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
59
+ import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
52
60
  import { findTokensReferencingTag } from "./token-store.ts";
53
61
 
54
62
  /**
@@ -1103,6 +1111,22 @@ async function handleNotesInner(
1103
1111
  }
1104
1112
  throw e;
1105
1113
  }
1114
+ // Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
1115
+ // FTS5-vocabulary scan) and ONLY computed on the already-rare
1116
+ // empty-result path, mirroring `unknown_tag`'s did_you_mean.
1117
+ // Gated on `tagScope.allowed === null` (unscoped) — same stance
1118
+ // as `collectUnknownTagWarnings` below: the FTS5 vocabulary spans
1119
+ // the whole vault regardless of scope, so surfacing a suggestion
1120
+ // to a scoped token would leak out-of-scope content's existence
1121
+ // across the scope boundary. Unlike the MCP path, REST enforces
1122
+ // tag-scope inline (no post-hoc wrapper to strip it for us), so
1123
+ // the gate has to live here explicitly.
1124
+ if (rawResults.length === 0 && tagScope.allowed === null) {
1125
+ const suggestion = computeSearchDidYouMean(db, search);
1126
+ if (suggestion) {
1127
+ searchWarnings.push(searchDidYouMeanWarning(search, suggestion));
1128
+ }
1129
+ }
1106
1130
  }
1107
1131
  // Tag-scope: drop any result the token isn't permitted to see. Filter
1108
1132
  // happens after the store query so an empty post-filter list still
@@ -1329,6 +1353,34 @@ async function handleNotesInner(
1329
1353
  if (contentRange.range && includeContent) {
1330
1354
  for (const n of output) applyContentRange(n, contentRange.range);
1331
1355
  }
1356
+ // vault#555 fix 3 — attach validation_status on reads too (mirrors
1357
+ // MCP query-notes). Additive, present only when a tag declares
1358
+ // `fields`. Runs BEFORE metadata filtering so it sees full metadata
1359
+ // regardless of `include_metadata`. Scrubbed for a tag-scoped caller
1360
+ // (vault#555 auth review) so an out-of-scope co-tag's schema shape
1361
+ // doesn't leak — `results` here is already scope-filtered, but a
1362
+ // surviving note may carry an out-of-scope co-tag whose schema would
1363
+ // otherwise appear in its validation_status. No-op when unscoped.
1364
+ {
1365
+ const statusById = new Map(
1366
+ results.map((n) => [
1367
+ n.id,
1368
+ scrubValidationStatusByScope(
1369
+ store.validateNoteAgainstSchemas({
1370
+ path: n.path,
1371
+ tags: n.tags,
1372
+ metadata: n.metadata as Record<string, unknown> | undefined,
1373
+ }),
1374
+ tagScope.allowed,
1375
+ tagScope.raw,
1376
+ ),
1377
+ ]),
1378
+ );
1379
+ for (const n of output as any[]) {
1380
+ const status = statusById.get(n.id);
1381
+ if (status) n.validation_status = status;
1382
+ }
1383
+ }
1332
1384
  if (inclMeta !== undefined && inclMeta !== true) {
1333
1385
  output = output.map((n: any) => filterMetadata(n, inclMeta));
1334
1386
  }
@@ -1452,6 +1504,14 @@ async function handleNotesInner(
1452
1504
  }
1453
1505
 
1454
1506
  const created: Note[] = [];
1507
+ // Structured `links` are resolved in a second pass, after every note
1508
+ // in this batch has been created (vault#555) — resolving inline (the
1509
+ // old behavior) meant a link from item 0 to item 1's path silently
1510
+ // dropped, since item 1 didn't exist yet at the moment item 0's links
1511
+ // were processed. Mirrors the MCP create-note fix exactly (both
1512
+ // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1513
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1514
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
1455
1515
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
1456
1516
  // failure (path conflict, etc.) rolls back every prior insert. Without
1457
1517
  // this, callers got half-applied batches where the prefix landed and
@@ -1487,16 +1547,36 @@ async function handleNotesInner(
1487
1547
  via: writeCtx.via,
1488
1548
  });
1489
1549
 
1490
- // Create explicit links
1491
1550
  if (item.links) {
1492
- for (const link of item.links as { target: string; relationship: string }[]) {
1493
- const target = await resolveNote(store, link.target);
1494
- if (target) await store.createLink(note.id, target.id, link.relationship);
1495
- }
1551
+ pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
1496
1552
  }
1497
1553
 
1498
1554
  created.push((await store.getNote(note.id)) ?? note);
1499
1555
  }
1556
+
1557
+ // --- Resolve structured links (vault#555) ---
1558
+ // Same semantics as [[wikilinks]]: ID or path/title match, tried now
1559
+ // that every sibling note in this batch exists. A target that still
1560
+ // doesn't resolve is queued for lazy resolution (backfills
1561
+ // automatically when a matching note is created later) and surfaces
1562
+ // an `unresolved_link` warning naming the target — never silent.
1563
+ for (const { sourceId, links } of pendingLinks) {
1564
+ for (const link of links) {
1565
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1566
+ if (targetId) {
1567
+ await store.createLink(sourceId, targetId, link.relationship);
1568
+ } else {
1569
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1570
+ list.push({
1571
+ code: "unresolved_link",
1572
+ 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.`,
1573
+ target: link.target,
1574
+ relationship: link.relationship,
1575
+ });
1576
+ linkWarningsByNote.set(sourceId, list);
1577
+ }
1578
+ }
1579
+ }
1500
1580
  };
1501
1581
  try {
1502
1582
  await (batched ? transactionAsync(db, runBatch) : runBatch());
@@ -1561,8 +1641,14 @@ async function handleNotesInner(
1561
1641
  // Attach `validation_status` so HTTP create-note matches the MCP
1562
1642
  // surface (vault#287). `attachValidationStatus` returns the note
1563
1643
  // unchanged when no tag declares fields, so vaults without any tag
1564
- // schemas see no behavior change.
1565
- const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1644
+ // schemas see no behavior change. Fold in any `unresolved_link`
1645
+ // warnings (vault#555) additive, present only when this note's
1646
+ // `links` had a target that didn't resolve.
1647
+ const final = refreshed.map((n) => {
1648
+ const validated = attachValidationStatus(store, db, n);
1649
+ const warnings = linkWarningsByNote.get(n.id);
1650
+ return warnings && warnings.length > 0 ? { ...validated, warnings } : validated;
1651
+ });
1566
1652
  return json(body.notes ? final : final[0], 201);
1567
1653
  }
1568
1654
 
@@ -1718,6 +1804,24 @@ async function handleNotesInner(
1718
1804
  const contentRange = parseContentRangeQuery(url, includeContent);
1719
1805
  if (contentRange.error) return contentRange.error;
1720
1806
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
1807
+ // vault#555 fix 3 — mirror the MCP query-notes fix: attach
1808
+ // validation_status on reads too, not just on the one-time create/update
1809
+ // write response. See core/src/mcp.ts's query-notes handler for the
1810
+ // full rationale. Scrubbed for a tag-scoped caller (vault#555 auth
1811
+ // review) so an out-of-scope co-tag's schema shape doesn't leak — no-op
1812
+ // for unscoped callers (tagScope.raw === null).
1813
+ {
1814
+ const status = scrubValidationStatusByScope(
1815
+ store.validateNoteAgainstSchemas({
1816
+ path: note.path,
1817
+ tags: note.tags,
1818
+ metadata: note.metadata as Record<string, unknown> | undefined,
1819
+ }),
1820
+ tagScope.allowed,
1821
+ tagScope.raw,
1822
+ );
1823
+ if (status) result.validation_status = status;
1824
+ }
1721
1825
  const expand = parseExpandParams(url, db, tagScope);
1722
1826
  if (expand && includeContent && typeof result.content === "string") {
1723
1827
  expand.ctx.expanded.add(note.id);
@@ -1811,22 +1915,35 @@ async function handleNotesInner(
1811
1915
  // fresh note).
1812
1916
  // - Missing target notes skip silently (mirrors MCP).
1813
1917
  const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1918
+ // `unresolved_link` warnings (vault#555) — a target that doesn't
1919
+ // resolve is queued for lazy resolution (backfills automatically
1920
+ // when a matching note is created later), never silently dropped.
1921
+ const createWarnings: QueryWarning[] = [];
1814
1922
  if (linksAdd) {
1815
1923
  for (const link of linksAdd) {
1816
- const target = await resolveNote(store, link.target);
1817
- if (target) {
1818
- await store.createLink(created.id, target.id, link.relationship, link.metadata);
1924
+ const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
1925
+ if (targetId) {
1926
+ await store.createLink(created.id, targetId, link.relationship, link.metadata);
1927
+ } else {
1928
+ createWarnings.push({
1929
+ code: "unresolved_link",
1930
+ 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.`,
1931
+ target: link.target,
1932
+ relationship: link.relationship,
1933
+ });
1819
1934
  }
1820
1935
  }
1821
1936
  }
1822
1937
  const final = await store.getNote(created.id);
1823
1938
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
1824
- const validated = attachValidationStatus(store, db, final);
1939
+ const validated: any = attachValidationStatus(store, db, final);
1940
+ if (createWarnings.length > 0) validated.warnings = createWarnings;
1825
1941
  const includeContentResp = body.include_content !== false;
1826
1942
  if (includeContentResp) return json({ ...validated, created: true });
1827
1943
  const lean: any = toNoteIndex(validated);
1828
1944
  const vs = (validated as any).validation_status;
1829
1945
  if (vs !== undefined) lean.validation_status = vs;
1946
+ if (createWarnings.length > 0) lean.warnings = createWarnings;
1830
1947
  lean.created = true;
1831
1948
  return json(lean);
1832
1949
  }
@@ -1969,7 +2086,7 @@ async function handleNotesInner(
1969
2086
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1970
2087
  if (linksRemove) {
1971
2088
  for (const link of linksRemove) {
1972
- const target = await resolveNote(store, link.target);
2089
+ const target = resolveStructuredLinkNote(db, link.target);
1973
2090
  if (!target) continue;
1974
2091
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1975
2092
  if (link.relationship === "wikilink" && target.path) {
@@ -2046,9 +2163,21 @@ async function handleNotesInner(
2046
2163
  gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
2047
2164
  }
2048
2165
 
2049
- if (Object.keys(updates).length > 0) {
2166
+ // vault#555 fix 2 — tag and link mutations must bump `updated_at` too,
2167
+ // not just core-field (content/path/metadata) changes. Mirrors the MCP
2168
+ // `update-note` fix exactly: a tags-only or links-only PATCH with
2169
+ // `force: true` (no `if_updated_at`) left `updates` empty, so
2170
+ // `store.updateNote` was never even called and `updated_at` never
2171
+ // moved despite a real tags/note_tags or links change.
2172
+ const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
2173
+ const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
2174
+ if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
2050
2175
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
2051
- // write columns on the same UPDATE that bumps updated_at.
2176
+ // write columns on the same UPDATE that bumps updated_at. `updates`
2177
+ // may carry no core fields at all (a pure tag/link mutation) — that's
2178
+ // fine: `noteOps.updateNote` unconditionally SETs
2179
+ // `updated_at`/`last_updated_by`/`last_updated_via` whenever
2180
+ // `skipUpdatedAt` isn't set, so this still issues a real UPDATE.
2052
2181
  updates.actor = writeCtx.actor;
2053
2182
  updates.via = writeCtx.via;
2054
2183
  await store.updateNote(note.id, updates);
@@ -2068,11 +2197,24 @@ async function handleNotesInner(
2068
2197
  await store.untagNote(note.id, body.tags.remove);
2069
2198
  }
2070
2199
 
2071
- // Add links
2200
+ // Add links. `unresolved_link` warnings (vault#555) — a target that
2201
+ // doesn't resolve is queued for lazy resolution (backfills
2202
+ // automatically when a matching note is created later), never
2203
+ // silently dropped.
2204
+ const linkWarnings: QueryWarning[] = [];
2072
2205
  if (body.links?.add) {
2073
2206
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
2074
- const target = await resolveNote(store, link.target);
2075
- if (target) await store.createLink(note.id, target.id, link.relationship, link.metadata);
2207
+ const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2208
+ if (targetId) {
2209
+ await store.createLink(note.id, targetId, link.relationship, link.metadata);
2210
+ } else {
2211
+ linkWarnings.push({
2212
+ code: "unresolved_link",
2213
+ 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.`,
2214
+ target: link.target,
2215
+ relationship: link.relationship,
2216
+ });
2217
+ }
2076
2218
  }
2077
2219
  }
2078
2220
 
@@ -2107,6 +2249,7 @@ async function handleNotesInner(
2107
2249
  tagScope.raw,
2108
2250
  );
2109
2251
  }
2252
+ if (linkWarnings.length > 0) validated.warnings = linkWarnings;
2110
2253
  const includeContentResp = body.include_content !== false;
2111
2254
  // `created: false` is appended to every update-path response so
2112
2255
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -2119,6 +2262,7 @@ async function handleNotesInner(
2119
2262
  // Carry the link echo across the lean conversion — `toNoteIndex`
2120
2263
  // drops unknown fields, same as the `validation_status` recipe above.
2121
2264
  if (validated.links !== undefined) lean.links = validated.links;
2265
+ if (linkWarnings.length > 0) lean.warnings = linkWarnings;
2122
2266
  lean.created = false;
2123
2267
  return json(lean);
2124
2268
  } catch (e: any) {
@@ -2619,11 +2763,22 @@ export async function handleTags(
2619
2763
  // — silent 200s on main — is non-indexed type conflicts and indexed-flag
2620
2764
  // conflicts. See `collectCrossTagFieldViolations`'s doc comment.
2621
2765
  if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
2766
+ const incomingFields = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
2767
+ // vault#555 fix 5 — fold the own-field `default`/`type` checks into
2768
+ // the SAME bundled report as the cross-tag ones. Before this, a bad
2769
+ // `default` (and, since fix 4 added the check, an unrecognized
2770
+ // `type`) went through `store.upsertTagRecord`'s fail-fast pre-
2771
+ // validate below INSTEAD — a single-violation 400 that silently never
2772
+ // mentioned any OTHER bad field in the same call. `unsupported_
2773
+ // indexed_type`/`invalid_field_name` deliberately stay OUT of this
2774
+ // bundle — REST's established single-violation `400
2775
+ // invalid_indexed_field` contract for those two is unchanged
2776
+ // (vault#478; see `collectCrossTagFieldViolations`'s doc comment).
2622
2777
  const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
2623
2778
  store.db,
2624
2779
  putTagName,
2625
- body.fields as Record<string, tagSchemaOps.TagFieldSchema>,
2626
- );
2780
+ incomingFields,
2781
+ ).concat(tagSchemaOps.collectOwnFieldDefaultAndTypeViolations(incomingFields));
2627
2782
  if (fieldViolations.length > 0) {
2628
2783
  // Tag-scope scrub (vault#554 auth-and-scope fold): the write is
2629
2784
  // still rejected, but a violation whose conflicting declarer is
@@ -2671,15 +2826,26 @@ export async function handleTags(
2671
2826
  if (err instanceof InvalidFieldDefaultError) {
2672
2827
  // vault#553 Decision B — a declared `default` doesn't conform to its
2673
2828
  // own field's type/enum. Own-field error (no cross-tag data), so no
2674
- // scrub needed. Mirrors IndexedFieldError's 400 shape/posturesee
2675
- // store.upsertTagRecord's pre-validate comment for why REST hits
2676
- // this (fail-fast, single violation) while MCP's update-tag usually
2677
- // reports it bundled via `TagFieldConflictError` instead.
2829
+ // scrub needed. DEFENSE-IN-DEPTH ONLY as of vault#555 fix 5 the
2830
+ // pre-check above (`collectOwnFieldDefaultAndTypeViolations`) now
2831
+ // catches every `invalid_default` BEFORE reaching `store.upsertTagRecord`
2832
+ // and reports it bundled via `tag_field_conflict` 422 instead; this
2833
+ // branch only fires for a caller that somehow bypasses that pre-check.
2678
2834
  return json(
2679
2835
  { error: err.message, error_type: "invalid_field_default", field: err.field },
2680
2836
  400,
2681
2837
  );
2682
2838
  }
2839
+ if (err instanceof InvalidFieldTypeError) {
2840
+ // vault#555 fix 4 — a declared `type` isn't one of the six
2841
+ // recognized values. Same defense-in-depth posture as
2842
+ // InvalidFieldDefaultError above — the pre-check catches this in
2843
+ // the normal path and reports it bundled instead.
2844
+ return json(
2845
+ { error: err.message, error_type: "invalid_field_type", field: err.field, type: err.type, valid_types: err.valid_types },
2846
+ 400,
2847
+ );
2848
+ }
2683
2849
  if (err instanceof ParentCycleError) {
2684
2850
  // vault#552: parent_names would close a cycle. Nothing was
2685
2851
  // 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
  });
package/src/tag-scope.ts CHANGED
@@ -83,6 +83,66 @@ export function filterNotesByTagScope<T extends Note>(
83
83
  return notes.filter((n) => noteWithinTagScope(n, allowed, rawRoots));
84
84
  }
85
85
 
86
+ /**
87
+ * Is a SINGLE tag name visible to this token? The per-tag core of
88
+ * `noteWithinTagScope` — exact allowlist membership OR string-form root
89
+ * match — pulled out so the `validation_status` scrub below (which reasons
90
+ * about a warning's `schema`/`loser_schema` tag NAMES, not a note's whole
91
+ * tag set) uses the identical visibility rule. `rawRoots === null` (unscoped)
92
+ * → always visible.
93
+ */
94
+ function tagVisibleInScope(
95
+ tag: string,
96
+ allowed: Set<string> | null,
97
+ rawRoots: string[] | null,
98
+ ): boolean {
99
+ if (rawRoots === null) return true;
100
+ if (allowed && allowed.has(tag)) return true;
101
+ const root = tag.split("/")[0];
102
+ return !!root && rawRoots.includes(root);
103
+ }
104
+
105
+ /**
106
+ * Scrub a note's `validation_status` so a tag-scoped caller can't learn the
107
+ * SCHEMA SHAPE (field name, type, enum values) of an OUT-OF-SCOPE tag that
108
+ * happens to co-tag a note it can otherwise see (vault#555 auth review;
109
+ * the #560 leak class). Repro: a scoped caller reading a note tagged both
110
+ * `mine` (in scope) and `project-manhattan` (out of scope) received
111
+ * `validation_status.warnings: [{ schema: "project-manhattan", message:
112
+ * "'codeword' must be one of [fizzbuzz] ...", ... }]` and
113
+ * `schemas: ["project-manhattan"]` — leaking that tag's field/enum.
114
+ *
115
+ * Core produces the FULL status (scope-unaware by architecture, same as
116
+ * every other core surface); this server-layer scrub — mirroring
117
+ * `scrubTagFieldViolationsByScope` — drops any warning whose declaring
118
+ * tag (`schema`, and for a `schema_conflict` the overridden `loser_schema`)
119
+ * is out of scope, and filters the `schemas` array to visible tags. When
120
+ * nothing in-scope remains (the note's only schema-declaring tag was
121
+ * out-of-scope), returns `undefined` so the caller omits `validation_status`
122
+ * entirely — byte-identical to a note with no applicable schema. Unscoped
123
+ * callers (`rawRoots === null`) get the status untouched.
124
+ *
125
+ * Applied at every point a scoped caller receives `validation_status`: the
126
+ * MCP `query-notes` wrapper and the REST `GET /notes[/{id}]` read paths.
127
+ */
128
+ export function scrubValidationStatusByScope<
129
+ S extends { schemas: string[]; warnings: Array<{ schema: string; loser_schema?: string }> },
130
+ >(
131
+ status: S | null | undefined,
132
+ allowed: Set<string> | null,
133
+ rawRoots: string[] | null,
134
+ ): S | undefined {
135
+ if (!status || rawRoots === null) return status ?? undefined;
136
+ const schemas = status.schemas.filter((s) => tagVisibleInScope(s, allowed, rawRoots));
137
+ const warnings = status.warnings.filter(
138
+ (w) =>
139
+ tagVisibleInScope(w.schema, allowed, rawRoots) &&
140
+ (w.loser_schema === undefined || tagVisibleInScope(w.loser_schema, allowed, rawRoots)),
141
+ );
142
+ if (schemas.length === 0 && warnings.length === 0) return undefined;
143
+ return { ...status, schemas, warnings };
144
+ }
145
+
86
146
  /**
87
147
  * For write paths: a note being created/updated must end up carrying at
88
148
  * least one tag inside the allowlist. `tags` is the post-write tag set