@openparachute/vault 0.7.0-rc.9 → 0.7.1

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/mcp-tools.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
- import { getNoteTags } from "../core/src/notes.ts";
10
+ import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
11
11
  import type { Note } from "../core/src/types.ts";
12
12
  import {
13
13
  buildVaultProjection,
@@ -28,6 +28,7 @@ import {
28
28
  scrubTagFieldViolationsByScope,
29
29
  scrubValidationStatusByScope,
30
30
  tagsWithinScope,
31
+ tagVisibleInScope,
31
32
  } from "./tag-scope.ts";
32
33
  import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
33
34
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
@@ -131,7 +132,9 @@ function resolveVaultCoordinates(): { hubOrigin: string; hubOriginKnown: boolean
131
132
  * `auth` is the resolved token for the caller and is captured by vault-info's
132
133
  * execute closure so the description-update branch can perform a secondary
133
134
  * scope check: the tool itself is gated at vault:read (so read-only callers
134
- * can fetch stats), but writing a new description requires vault:write.
135
+ * can fetch stats), but writing a new description requires vault:admin
136
+ * (tightened from vault:write by the write/admin re-tier — description is
137
+ * vault curation, not content).
135
138
  *
136
139
  * When omitted (internal callers that only inspect the tool list — no execute
137
140
  * path exercised), the description-update branch is disabled entirely.
@@ -158,6 +161,19 @@ export function generateScopedMcpTools(
158
161
  ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
159
162
  : undefined;
160
163
 
164
+ // Tag-scope for `query-notes`'s `aggregate` mode (top new-feature ask
165
+ // from a UX round): a rollup has no per-note shape to post-filter (the
166
+ // response is `[{group, value}]`, not notes), so scope has to be applied
167
+ // BEFORE core aggregates, not after. Same shared `allowedHolder` /
168
+ // `getAllowed()`-before-`orig()` ordering invariant as `expandVisibility`
169
+ // above — reads the resolved allowlist safely because the query-notes
170
+ // wrapper always awaits `getAllowed()` before invoking core's (synchronous
171
+ // use of this) execute. Unscoped sessions install no predicate → the
172
+ // aggregate runs core's fast direct-SQL path.
173
+ const aggregateVisibility = scoped
174
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
175
+ : undefined;
176
+
161
177
  // Tag-scope hop-guard for `near[]` (vault#439): a per-note predicate the
162
178
  // core BFS consults so it refuses to traverse THROUGH out-of-scope notes —
163
179
  // symmetric with find-path. Reads from the SAME shared `allowedHolder` the
@@ -211,11 +227,12 @@ export function generateScopedMcpTools(
211
227
 
212
228
  const tools = generateMcpTools(
213
229
  store,
214
- expandVisibility || nearTraversable || ifExistsVisible || writeContext || strictBypass
230
+ expandVisibility || nearTraversable || ifExistsVisible || aggregateVisibility || writeContext || strictBypass
215
231
  ? {
216
232
  ...(expandVisibility ? { expandVisibility } : {}),
217
233
  ...(nearTraversable ? { nearTraversable } : {}),
218
234
  ...(ifExistsVisible ? { ifExistsVisible } : {}),
235
+ ...(aggregateVisibility ? { aggregateVisibility } : {}),
219
236
  ...(writeContext ? { writeContext } : {}),
220
237
  ...(strictBypass ? { strictBypass } : {}),
221
238
  ...(onStrictBypass ? { onStrictBypass } : {}),
@@ -301,9 +318,11 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
301
318
  * - vault-info: filter projection.tags + projection.indexed_fields
302
319
  * to entries an in-scope tag contributes to
303
320
  *
304
- * Write-tool gating happens in handleScopedMcp at the verb-scope layer
305
- * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
306
- * token can't write outside its allowlist). See applyTagScopeWriteGuards.
321
+ * Mutating-tool gating happens in handleScopedMcp at the verb-scope layer
322
+ * (`vault:write` for create/update/delete-note, `vault:admin` for the
323
+ * tag-schema/taxonomy tools since the write/admin re-tier) AND inside each
324
+ * tool's wrapper here, so a tag-scoped token — whatever verb it holds —
325
+ * can't mutate outside its allowlist. See applyTagScopeWriteGuards.
307
326
  */
308
327
  function applyTagScopeWrappers(
309
328
  tools: McpToolDef[],
@@ -365,6 +384,29 @@ function applyTagScopeWrappers(
365
384
  const allowed = await getAllowed();
366
385
  const result = await orig(params);
367
386
  if (!allowed) return result;
387
+ // `aggregate` mode returns `[{group, value}]` rollup rows — no `.tags`
388
+ // to post-filter (and `noteWithinTagScope` would wrongly drop every
389
+ // row, since an absent `.tags` reads as "out of scope"). WHICH NOTES
390
+ // count was already narrowed INSIDE core via the `aggregateVisibility`
391
+ // predicate (see `generateScopedMcpTools`): fetch the full filtered
392
+ // set, keep only allowlisted notes, THEN aggregate.
393
+ //
394
+ // That note-level narrowing is NOT sufficient on its own under
395
+ // `group_by: "tag"`, though: a note can be in scope via one tag while
396
+ // ALSO carrying an out-of-scope co-tag (the same class of leak
397
+ // `scrubValidationStatusByScope` closes for `validation_status`), and a
398
+ // tag rollup's `group` values ARE tag names — the co-tag would surface
399
+ // directly as a group. Scrub group NAMES here, the same way every other
400
+ // tag-shaped output on this wrapper is scrubbed.
401
+ if ((params as any).aggregate) {
402
+ const groupBy = (params as any).aggregate?.group_by;
403
+ if (groupBy === "tag" && Array.isArray(result)) {
404
+ return result.filter(
405
+ (r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, allowed, rawTags),
406
+ );
407
+ }
408
+ return result;
409
+ }
368
410
  // Possible response shapes (vault#550 added the `warnings` variants):
369
411
  // - Array (legacy list, no cursor, no warnings)
370
412
  // - `{notes, next_cursor}` (cursor mode, vault#313)
@@ -478,20 +520,34 @@ function applyTagScopeWrappers(
478
520
  tags: Array.isArray(r.tags) ? r.tags : [],
479
521
  indexed_fields: Array.isArray(r.indexed_fields) ? r.indexed_fields : [],
480
522
  query_hints: Array.isArray(r.query_hints) ? r.query_hints : [],
523
+ // Unused by filterProjectionByScope (map is handled separately below,
524
+ // via a live recompute) — a placeholder to satisfy VaultProjection's
525
+ // shape.
526
+ map: { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 },
481
527
  };
482
528
  const filtered = filterProjectionByScope(partial, allowed);
483
529
  r.tags = filtered.tags;
484
530
  r.indexed_fields = filtered.indexed_fields;
531
+ // `map` (front-door structural rollup): post-hoc filtering the unscoped
532
+ // aggregate isn't possible for path-bucket counts (they need per-note
533
+ // tag membership, not just a tag-name allowlist over a precomputed
534
+ // rollup) — so re-run the cheap grouped-count query restricted to the
535
+ // resolved allowlist instead of filtering `orig`'s unscoped result.
536
+ if (r.map) {
537
+ r.map = getVaultMap(store.db, { tagFilter: [...allowed] });
538
+ }
485
539
  return r;
486
540
  });
487
541
 
488
542
  // ---- Write-side guards ----
489
543
  //
490
- // The verb-scope check (`vault:write`) is enforced at the dispatch layer
491
- // in handleScopedMcp. These wrappers add the second axis: a scoped
492
- // `vault:write` token can only mutate within its tag-allowlist, never
493
- // outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
494
- // tag name itself; note operations gate on the prospective tag set.
544
+ // The verb-scope check (`vault:write` for note ops, `vault:admin` for the
545
+ // tag-schema/taxonomy ops post-re-tier) is enforced at the dispatch layer
546
+ // in handleScopedMcp. These wrappers add the second axis: a scoped token
547
+ // can only mutate within its tag-allowlist, never outside it. Tag
548
+ // operations (`update-tag`, `delete-tag`, `rename-tag`, `merge-tags`) gate
549
+ // on the tag name(s) themselves; note operations gate on the prospective
550
+ // tag set.
495
551
 
496
552
  const forbidden = (msg: string): unknown => ({
497
553
  error: "Forbidden",
@@ -709,13 +765,15 @@ function overrideVaultInfo(
709
765
 
710
766
  if (params.description !== undefined) {
711
767
  // Secondary scope check: vault-info is read-gated so read-only callers
712
- // can fetch stats, but mutating the vault description requires write
713
- // for THIS vault. Without this, a vault:read token could bypass the
714
- // outer gate by passing `description` to a tool the outer gate
715
- // considers read-only.
716
- if (!auth || !hasScopeForVault(auth.scopes, vaultName, "write")) {
768
+ // can fetch stats, but mutating the vault description requires admin
769
+ // for THIS vault (was `write` tightened by the write/admin re-tier:
770
+ // writing the vault's own description/config is curation, same class
771
+ // as update-tag et al, not content authorship). Without this, a
772
+ // vault:read OR vault:write token could bypass the outer gate by
773
+ // passing `description` to a tool the outer gate considers read-only.
774
+ if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
717
775
  throw new Error(
718
- `Forbidden: updating the vault description requires the 'vault:write' scope (or 'vault:${vaultName}:write'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
776
+ `Forbidden: updating the vault description requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
719
777
  );
720
778
  }
721
779
  config.description = params.description as string;
@@ -747,6 +805,7 @@ function overrideVaultInfo(
747
805
  tags: projection.tags,
748
806
  indexed_fields: projection.indexed_fields,
749
807
  query_hints: projection.query_hints,
808
+ map: projection.map,
750
809
  };
751
810
 
752
811
  // A2: surface a pointer (path, not body) to the seeded onboarding guide so
package/src/routes.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note, QueryOpts } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import {
17
17
  collectUnknownTagWarnings,
@@ -28,9 +28,10 @@ import {
28
28
  resolveStructuredLinkNote,
29
29
  getUnresolvedLinksForNote,
30
30
  getUnresolvedLinksForNotes,
31
+ getContentWikilinkWarnings,
31
32
  } from "../core/src/wikilinks.ts";
32
33
  import { transactionAsync } from "../core/src/txn.ts";
33
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
34
+ import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, getVaultMap } from "../core/src/notes.ts";
34
35
  import { normalizePath } from "../core/src/paths.ts";
35
36
  import {
36
37
  parseContentRange,
@@ -62,6 +63,7 @@ import {
62
63
  scrubValidationStatusByScope,
63
64
  tagScopeForbidden,
64
65
  tagsWithinScope,
66
+ tagVisibleInScope,
65
67
  } from "./tag-scope.ts";
66
68
  import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
67
69
  import { findTokensReferencingTag } from "./token-store.ts";
@@ -850,6 +852,58 @@ export function parseNotesQueryOpts(url: URL): {
850
852
  return { queryOpts, hasSearch, hasNear, hasCursor };
851
853
  }
852
854
 
855
+ /**
856
+ * Parse the `?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`
857
+ * aggregation params (bracket-style, consistent with `meta[field][op]=`
858
+ * above) into an `AggregateSpec`. Absent entirely (none of the three keys
859
+ * present) → `{}` — no aggregate intent, the caller falls through to a
860
+ * normal query. `group_by` + `op` are required together when ANY of the
861
+ * three is present; `field` is optional at the parser level (its
862
+ * requiredness depends on `op`, enforced by `aggregateNotes` itself). Value
863
+ * validity beyond shape (indexed field, numeric type, sum-requires-field)
864
+ * is ALSO enforced by `aggregateNotes` — same FIELD_NOT_INDEXED /
865
+ * INVALID_QUERY contract every other query surface uses.
866
+ *
867
+ * Returns `{ aggregate? }` or `{ error }` (a 400 Response) on a malformed
868
+ * shape (missing `group_by`/`op`, or an unrecognized `op`).
869
+ */
870
+ function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Response } {
871
+ const groupBy = parseQuery(url, "aggregate[group_by]");
872
+ const op = parseQuery(url, "aggregate[op]");
873
+ const field = parseQuery(url, "aggregate[field]");
874
+ if (groupBy === null && op === null && field === null) return {};
875
+ if (groupBy === null || op === null) {
876
+ return {
877
+ error: json(
878
+ {
879
+ error: `aggregate requires both aggregate[group_by] and aggregate[op] — group_by is an indexed metadata field or "tag"; op is "count" or "sum".`,
880
+ code: "INVALID_QUERY",
881
+ error_type: "invalid_query",
882
+ field: "aggregate",
883
+ hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
884
+ },
885
+ 400,
886
+ ),
887
+ };
888
+ }
889
+ if (op !== "count" && op !== "sum") {
890
+ return {
891
+ error: json(
892
+ {
893
+ error: `invalid aggregate[op]: "${op}" — must be "count" or "sum"`,
894
+ code: "INVALID_QUERY",
895
+ error_type: "invalid_query",
896
+ field: "aggregate.op",
897
+ got: op,
898
+ hint: `pass "count" or "sum"`,
899
+ },
900
+ 400,
901
+ ),
902
+ };
903
+ }
904
+ return { aggregate: { group_by: groupBy, op, field: field ?? undefined } };
905
+ }
906
+
853
907
  /**
854
908
  * Parse include_metadata query param.
855
909
  * - absent/null → undefined (all metadata, default)
@@ -907,6 +961,18 @@ function parseExpandParams(
907
961
  * differing only by extension (vault#330 S1). When the path is
908
962
  * ambiguous and no extension hint is supplied, `getNoteByPath` throws
909
963
  * `AmbiguousPathError` — REST handlers catch it and return 409.
964
+ *
965
+ * Title fallback (additive): when id AND path/extension both miss
966
+ * cleanly (no throw), tries an H1-title match via `getNoteByTitle` — the
967
+ * note whose first `# ` content line equals `idOrPath`, only when
968
+ * exactly one note has that title. Mirrors the MCP-layer `resolveNote`
969
+ * in core/src/mcp.ts and `[[wikilink]]` resolution (core/src/wikilinks.ts);
970
+ * exact id/path always wins first. Skipped (falls through to "not found")
971
+ * when `store.db` isn't available — the `Store` interface documents `db`
972
+ * as always present on the concrete class, but a handful of tests pass a
973
+ * minimal duck-typed stub (e.g. `handleViewNote`'s coverage in
974
+ * published.test.ts) that only implements `getNote`/`getNoteByPath`; this
975
+ * fallback must never turn that into a crash.
910
976
  */
911
977
  async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
912
978
  const byId = await store.getNote(idOrPath);
@@ -916,7 +982,9 @@ async function resolveNote(store: Store, idOrPath: string): Promise<Note | null>
916
982
  const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
917
983
  if (explicit) return explicit;
918
984
  }
919
- return await store.getNoteByPath(idOrPath);
985
+ const byPath = await store.getNoteByPath(idOrPath);
986
+ if (byPath) return byPath;
987
+ return store.db ? getNoteByTitle(store.db, idOrPath) : null;
920
988
  }
921
989
 
922
990
  async function requireNote(store: Store, idOrPath: string): Promise<Note> {
@@ -1241,6 +1309,87 @@ async function handleNotesInner(
1241
1309
  400,
1242
1310
  );
1243
1311
  }
1312
+
1313
+ // Aggregation / rollup mode (top new-feature ask from a UX round).
1314
+ // Mutually exclusive with cursor/near — a rollup returns one row per
1315
+ // group, not a paginated/graph-scoped note list — so reject those
1316
+ // combos loudly before touching the DB, then short-circuit: none of
1317
+ // the normal-query output machinery below (expand, include_links,
1318
+ // metadata filtering, ...) applies to rollup rows.
1319
+ const aggregateParsed = parseAggregateParam(url);
1320
+ if (aggregateParsed.error) return aggregateParsed.error;
1321
+ if (aggregateParsed.aggregate) {
1322
+ if (cursorMode) {
1323
+ return json(
1324
+ {
1325
+ error: "aggregate is incompatible with cursor pagination — a rollup has no watermark to page through.",
1326
+ code: "INVALID_QUERY",
1327
+ error_type: "invalid_query",
1328
+ field: "aggregate",
1329
+ hint: "drop `cursor` when using `aggregate`",
1330
+ },
1331
+ 400,
1332
+ );
1333
+ }
1334
+ if (nearNoteIdEarly) {
1335
+ return json(
1336
+ {
1337
+ error: "aggregate is incompatible with near (graph neighborhood).",
1338
+ code: "INVALID_QUERY",
1339
+ error_type: "invalid_query",
1340
+ field: "aggregate",
1341
+ hint: "drop `near` when using `aggregate`",
1342
+ },
1343
+ 400,
1344
+ );
1345
+ }
1346
+ try {
1347
+ let rows;
1348
+ if (tagScope.raw === null) {
1349
+ rows = await store.aggregateNotes({ ...queryOpts, aggregate: aggregateParsed.aggregate });
1350
+ } else {
1351
+ // Tag-scoped: filter to visible notes FIRST — fetch every note
1352
+ // the OTHER filters match (unpaginated, same `limit: 1000000`
1353
+ // "get everything" convention core's `syncAllWikilinks` uses),
1354
+ // narrow via the SAME `filterNotesByTagScope` every other read
1355
+ // path applies, THEN aggregate over just that visible id set
1356
+ // (reusing the `ids` filter `near` already pushes into SQL).
1357
+ const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
1358
+ const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1359
+ rows = visible.length === 0
1360
+ ? []
1361
+ : await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
1362
+ // That note-level narrowing isn't sufficient on its own under
1363
+ // `group_by: "tag"`: a note can be in scope via one tag while
1364
+ // ALSO carrying an out-of-scope co-tag, and a tag rollup's
1365
+ // `group` values ARE tag names — the co-tag would otherwise
1366
+ // surface directly as a group. Scrub group NAMES too, mirroring
1367
+ // the MCP wrapper's identical fix (src/mcp-tools.ts).
1368
+ if (aggregateParsed.aggregate.group_by === "tag") {
1369
+ rows = rows.filter(
1370
+ (r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, tagScope.allowed, tagScope.raw),
1371
+ );
1372
+ }
1373
+ }
1374
+ return json(rows);
1375
+ } catch (e: any) {
1376
+ if (e && e.name === "QueryError") {
1377
+ return json(
1378
+ {
1379
+ error: e.message,
1380
+ code: e.code ?? "INVALID_QUERY",
1381
+ error_type: e.error_type ?? "invalid_query",
1382
+ ...(e.field !== undefined ? { field: e.field } : {}),
1383
+ ...(e.got !== undefined ? { got: e.got } : {}),
1384
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1385
+ },
1386
+ 400,
1387
+ );
1388
+ }
1389
+ throw e;
1390
+ }
1391
+ }
1392
+
1244
1393
  // Warnings channel (vault#550). `unknown_tag` stays structured-query
1245
1394
  // only (skipped entirely for a tag-scoped session:
1246
1395
  // `collectUnknownTagWarnings` resolves `did_you_mean` against the
@@ -1537,6 +1686,17 @@ async function handleNotesInner(
1537
1686
  // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1538
1687
  const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1539
1688
  const linkWarningsByNote = new Map<string, QueryWarning[]>();
1689
+ const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
1690
+ const list = linkWarningsByNote.get(noteId) ?? [];
1691
+ list.push(warning);
1692
+ linkWarningsByNote.set(noteId, list);
1693
+ };
1694
+ // Content wikilinks whose note+content pair needs an `unresolved_link`/
1695
+ // `ambiguous_link` warning check (vault#570) — deferred to the same
1696
+ // second pass as `pendingLinks` for the same forward-ref-within-batch
1697
+ // reason (a content `[[wikilink]]` to a batch sibling created later
1698
+ // resolves via the pending-wikilink backfill by the time this runs).
1699
+ const contentWikilinkNotes: { noteId: string; content: string }[] = [];
1540
1700
  // `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
1541
1701
  // create-note tool exactly (same contract, independent REST-layer
1542
1702
  // reimplementation sharing the same core primitives). See that file's
@@ -1610,6 +1770,14 @@ async function handleNotesInner(
1610
1770
  });
1611
1771
  }
1612
1772
 
1773
+ // Content-wikilink warnings (vault#570) — this branch's content
1774
+ // update (if any) already ran `syncWikilinks` inside
1775
+ // `store.updateNote` above; queue for the shared second-pass
1776
+ // classification below.
1777
+ if (updates.content !== undefined) {
1778
+ contentWikilinkNotes.push({ noteId: result.id, content: updates.content });
1779
+ }
1780
+
1613
1781
  if (incomingTags.length > 0) {
1614
1782
  await store.tagNote(result.id, incomingTags);
1615
1783
  // Redundant with the outer batch loop's applySchemaDefaults (this
@@ -1705,6 +1873,13 @@ async function handleNotesInner(
1705
1873
  pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
1706
1874
  }
1707
1875
 
1876
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
1877
+ // already ran `syncWikilinks` (gated on `content` truthy, same
1878
+ // condition here); queue for the shared second-pass classification.
1879
+ if (item.content) {
1880
+ contentWikilinkNotes.push({ noteId: note.id, content: item.content });
1881
+ }
1882
+
1708
1883
  created.push((await store.getNote(note.id)) ?? note);
1709
1884
  }
1710
1885
 
@@ -1713,24 +1888,40 @@ async function handleNotesInner(
1713
1888
  // that every sibling note in this batch exists. A target that still
1714
1889
  // doesn't resolve is queued for lazy resolution (backfills
1715
1890
  // automatically when a matching note is created later) and surfaces
1716
- // an `unresolved_link` warning naming the target never silent.
1891
+ // an `unresolved_link` warning naming the target. A target that
1892
+ // matched ≥2 notes (vault#570) is neither linked nor queued —
1893
+ // surfaces a distinct `ambiguous_link` warning instead. Never silent.
1717
1894
  for (const { sourceId, links } of pendingLinks) {
1718
1895
  for (const link of links) {
1719
- const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1720
- if (targetId) {
1721
- await store.createLink(sourceId, targetId, link.relationship);
1896
+ const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1897
+ if (outcome.status === "resolved") {
1898
+ await store.createLink(sourceId, outcome.note_id, link.relationship);
1899
+ } else if (outcome.status === "ambiguous") {
1900
+ pushLinkWarning(sourceId, {
1901
+ code: "ambiguous_link",
1902
+ 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.`,
1903
+ target: link.target,
1904
+ relationship: link.relationship,
1905
+ candidate_count: outcome.candidates.length,
1906
+ });
1722
1907
  } else {
1723
- const list = linkWarningsByNote.get(sourceId) ?? [];
1724
- list.push({
1908
+ pushLinkWarning(sourceId, {
1725
1909
  code: "unresolved_link",
1726
1910
  message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1727
1911
  target: link.target,
1728
1912
  relationship: link.relationship,
1729
1913
  });
1730
- linkWarningsByNote.set(sourceId, list);
1731
1914
  }
1732
1915
  }
1733
1916
  }
1917
+
1918
+ // --- Content-wikilink warnings (vault#570) ---
1919
+ // Same forward-ref-aware timing as the structured-links pass above.
1920
+ for (const { noteId, content } of contentWikilinkNotes) {
1921
+ for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
1922
+ pushLinkWarning(noteId, warning);
1923
+ }
1924
+ }
1734
1925
  };
1735
1926
  try {
1736
1927
  await (batched ? transactionAsync(db, runBatch) : runBatch());
@@ -2096,15 +2287,25 @@ async function handleNotesInner(
2096
2287
  // fresh note).
2097
2288
  // - Missing target notes skip silently (mirrors MCP).
2098
2289
  const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
2099
- // `unresolved_link` warnings (vault#555) — a target that doesn't
2100
- // resolve is queued for lazy resolution (backfills automatically
2101
- // when a matching note is created later), never silently dropped.
2290
+ // `unresolved_link` / `ambiguous_link` warnings (vault#555, vault#570)
2291
+ // — a target that doesn't resolve is queued for lazy resolution
2292
+ // (backfills automatically when a matching note is created later),
2293
+ // and a target matching ≥2 notes is neither linked nor queued.
2294
+ // Never silently dropped.
2102
2295
  const createWarnings: QueryWarning[] = [];
2103
2296
  if (linksAdd) {
2104
2297
  for (const link of linksAdd) {
2105
- const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2106
- if (targetId) {
2107
- await store.createLink(created.id, targetId, link.relationship, link.metadata);
2298
+ const outcome = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2299
+ if (outcome.status === "resolved") {
2300
+ await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
2301
+ } else if (outcome.status === "ambiguous") {
2302
+ createWarnings.push({
2303
+ code: "ambiguous_link",
2304
+ 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.`,
2305
+ target: link.target,
2306
+ relationship: link.relationship,
2307
+ candidate_count: outcome.candidates.length,
2308
+ });
2108
2309
  } else {
2109
2310
  createWarnings.push({
2110
2311
  code: "unresolved_link",
@@ -2115,6 +2316,13 @@ async function handleNotesInner(
2115
2316
  }
2116
2317
  }
2117
2318
  }
2319
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
2320
+ // already ran `syncWikilinks`; this is the single-note (non-batch)
2321
+ // create path, so there's no batch-sibling forward-ref to wait for
2322
+ // — compute right away against the committed content.
2323
+ if (content) {
2324
+ createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
2325
+ }
2118
2326
  const final = await store.getNote(created.id);
2119
2327
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
2120
2328
  const validated: any = attachValidationStatus(store, db, final);
@@ -2352,6 +2560,14 @@ async function handleNotesInner(
2352
2560
  // moved despite a real tags/note_tags or links change.
2353
2561
  const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
2354
2562
  const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
2563
+ // Content-wikilink warnings (vault#570) gate on the SAME condition
2564
+ // `store.updateNote`/`syncWikilinks` use to decide whether to re-sync
2565
+ // content wikilinks at all — a tags/links-only PATCH must not
2566
+ // spuriously re-warn about pre-existing broken links this call never
2567
+ // touched.
2568
+ const contentChanged = updates.content !== undefined
2569
+ || updates.append !== undefined
2570
+ || updates.prepend !== undefined;
2355
2571
  if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
2356
2572
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
2357
2573
  // write columns on the same UPDATE that bumps updated_at. `updates`
@@ -2378,16 +2594,25 @@ async function handleNotesInner(
2378
2594
  await store.untagNote(note.id, body.tags.remove);
2379
2595
  }
2380
2596
 
2381
- // Add links. `unresolved_link` warnings (vault#555) — a target that
2382
- // doesn't resolve is queued for lazy resolution (backfills
2383
- // automatically when a matching note is created later), never
2384
- // silently dropped.
2597
+ // Add links. `unresolved_link` / `ambiguous_link` warnings (vault#555,
2598
+ // vault#570) — a target that doesn't resolve is queued for lazy
2599
+ // resolution (backfills automatically when a matching note is created
2600
+ // later); a target matching ≥2 notes is neither linked nor queued.
2601
+ // Never silently dropped.
2385
2602
  const linkWarnings: QueryWarning[] = [];
2386
2603
  if (body.links?.add) {
2387
2604
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
2388
- const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2389
- if (targetId) {
2390
- await store.createLink(note.id, targetId, link.relationship, link.metadata);
2605
+ const outcome = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2606
+ if (outcome.status === "resolved") {
2607
+ await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
2608
+ } else if (outcome.status === "ambiguous") {
2609
+ linkWarnings.push({
2610
+ code: "ambiguous_link",
2611
+ 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.`,
2612
+ target: link.target,
2613
+ relationship: link.relationship,
2614
+ candidate_count: outcome.candidates.length,
2615
+ });
2391
2616
  } else {
2392
2617
  linkWarnings.push({
2393
2618
  code: "unresolved_link",
@@ -2409,6 +2634,13 @@ async function handleNotesInner(
2409
2634
  // `toNoteIndex` drops unknown fields).
2410
2635
  const updatedNote = await store.getNote(note.id);
2411
2636
  if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
2637
+ // Content-wikilink warnings (vault#570) — `store.updateNote` above
2638
+ // already ran `syncWikilinks` when `contentChanged`; this PATCH
2639
+ // handles a single note (no batch-sibling forward-ref to wait for),
2640
+ // so compute right away against the committed content.
2641
+ if (contentChanged) {
2642
+ linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
2643
+ }
2412
2644
  const validated: any = attachValidationStatus(store, db, updatedNote);
2413
2645
  // Echo hydrated links when a link mutation was part of this request,
2414
2646
  // OR the caller explicitly asked for them via `?include_links=true`
@@ -3224,6 +3456,13 @@ export async function handleVault(
3224
3456
  * configured AND available), which is what a surface gates its mic on.
3225
3457
  */
3226
3458
  resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
3459
+ /**
3460
+ * Tag-scope allowlist (front-door structural map). Threaded from
3461
+ * routing.ts the same way every other read handler gets it. Unscoped
3462
+ * (`NO_TAG_SCOPE`) by default so every pre-existing call site — none of
3463
+ * which pass this — is unaffected.
3464
+ */
3465
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
3227
3466
  ): Promise<Response> {
3228
3467
  const url = new URL(req.url);
3229
3468
 
@@ -3233,6 +3472,16 @@ export async function handleVault(
3233
3472
  // and available. `minutes_remaining` is omitted (cloud/plan concern;
3234
3473
  // self-host is unmetered). This is the field Notes gates the mic on.
3235
3474
  result.transcription = await resolveCapability();
3475
+ // Front-door structural map — ALWAYS included (unlike `stats`, which is
3476
+ // include_stats-gated): three cheap grouped-COUNT queries, so a fresh
3477
+ // reader orients in one call. Scope-aware: a tag-scoped token's map
3478
+ // covers only notes reachable through an in-scope tag (`tagFilter`
3479
+ // restricts the underlying query rather than post-hoc filtering an
3480
+ // unscoped rollup — see `getVaultMap`'s doc comment for why).
3481
+ result.map =
3482
+ tagScope.raw === null
3483
+ ? getVaultMap(store.db)
3484
+ : getVaultMap(store.db, { tagFilter: [...(tagScope.allowed ?? [])] });
3236
3485
  if (parseBool(parseQuery(url, "include_stats"), false)) {
3237
3486
  result.stats = await store.getVaultStats();
3238
3487
  }