@openparachute/vault 0.6.0-rc.1 → 0.6.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.
Files changed (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/routes.ts CHANGED
@@ -11,13 +11,22 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
+ import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
18
+ import {
19
+ parseContentRange,
20
+ applyContentRange,
21
+ contentRangeRequiresContent,
22
+ type ContentRange,
23
+ } from "../core/src/content-range.ts";
17
24
  import { attachValidationStatus } from "../core/src/mcp.ts";
18
25
  import * as linkOps from "../core/src/links.ts";
19
26
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
20
27
  import {
28
+ buildExpandVisibility,
29
+ filterHydratedLinksByTagScope,
21
30
  filterNotesByTagScope,
22
31
  noteWithinTagScope,
23
32
  tagScopeForbidden,
@@ -44,8 +53,16 @@ import {
44
53
  } from "../core/src/expand.ts";
45
54
  import { join, extname, normalize } from "path";
46
55
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
47
- import { vaultDir } from "./config.ts";
56
+ import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
48
57
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
58
+ // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
59
+ // of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
60
+ import { invalidateUsageCache } from "./usage.ts";
61
+
62
+ // Re-export `assetsDir` (now defined in config.ts) so the existing callers
63
+ // that import it from this module — mirror-deps, mirror-routes, server,
64
+ // triggers, cli — keep working unchanged.
65
+ export { assetsDir };
49
66
 
50
67
  // ---------------------------------------------------------------------------
51
68
  // Helpers
@@ -71,11 +88,58 @@ function parseQuery(url: URL, key: string): string | null {
71
88
  return url.searchParams.get(key);
72
89
  }
73
90
 
91
+ /**
92
+ * Parse `link_count_direction` (vault feedback #4). Defaults to "both";
93
+ * anything other than the three known values falls back to "both" so a
94
+ * typo silently degrades to the documented default rather than erroring.
95
+ *
96
+ * Tag-scope note (symmetric with the MCP param description): `linkCount`
97
+ * is a raw degree that MAY include edges to notes a tag-scoped token can't
98
+ * see — the tag-scope filter runs post-query, over the result notes, not
99
+ * their neighbors. Only the number leaks, not the neighbor.
100
+ */
101
+ function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
102
+ const v = url.searchParams.get("link_count_direction");
103
+ if (v === "outbound" || v === "inbound") return v;
104
+ return "both";
105
+ }
106
+
74
107
  function parseQueryList(url: URL, key: string): string[] | undefined {
75
108
  const val = url.searchParams.get(key);
76
109
  return val ? val.split(",") : undefined;
77
110
  }
78
111
 
112
+ /**
113
+ * Parse `?content_offset=` / `?content_length=` (content range — bounded
114
+ * reads for large notes; unit is UTF-8 bytes, see core/src/content-range.ts
115
+ * for the codepoint-boundary slicing rules). Returns `{ range: null }` when
116
+ * neither param is present (response byte-identical to the no-pagination
117
+ * shape), or `{ error }` (400 INVALID_QUERY) on invalid values or when the
118
+ * response shape excludes content — range params on a content-less shape
119
+ * error loudly rather than silently no-op, same policy as `?expand=`.
120
+ */
121
+ function parseContentRangeQuery(
122
+ url: URL,
123
+ includeContent: boolean,
124
+ ): { range: ContentRange | null; error?: Response } {
125
+ try {
126
+ const range = parseContentRange(
127
+ parseQuery(url, "content_offset") ?? undefined,
128
+ parseQuery(url, "content_length") ?? undefined,
129
+ );
130
+ if (range && !includeContent) throw contentRangeRequiresContent();
131
+ return { range };
132
+ } catch (e: any) {
133
+ // Duck-type on `name` — core is a separate module, so `instanceof`
134
+ // is fragile across bundling boundaries (same note as the QueryError
135
+ // handling in the structured-query path below).
136
+ if (e && e.name === "QueryError") {
137
+ return { range: null, error: json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400) };
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+
79
143
  /**
80
144
  * Parse the extension query parameter (vault#328). Two accepted shapes:
81
145
  * - `?extension=csv` (single value → string)
@@ -353,6 +417,180 @@ function parseMetaBrackets(url: URL): {
353
417
  return result;
354
418
  }
355
419
 
420
+ /**
421
+ * Parse the `?metadata=<json>` alias on GET /api/notes — the JSON-object form
422
+ * of the metadata filter, symmetric with the nested `metadata` object MCP
423
+ * forwards verbatim (`core/src/mcp.ts`). The value is a JSON object of the form
424
+ * `{"field":{"op":value}}` (operator query) or `{"field":value}` (shorthand
425
+ * equality via the engine's json_extract fallback).
426
+ *
427
+ * This exists because the bracket grammar (`?meta[field][op]=value`) couldn't
428
+ * see a `metadata=` param at all — it was silently dropped, and the query
429
+ * returned ALL tag-matching notes (a silent wrong result, not an error).
430
+ *
431
+ * We do NOT validate operators here — the parsed object lowers straight into
432
+ * `queryNotes`, where `validateOperatorObject` raises a loud 400 on unknown
433
+ * operators (caught by the QueryError handler in handleNotes). We only enforce
434
+ * that the param parses and is a non-null, non-array plain object; anything
435
+ * else is a malformed filter the engine can't consume.
436
+ *
437
+ * An empty object (`metadata={}`) carries no filter intent, so it's treated as
438
+ * absent: it sets no metadata filter AND doesn't trip the both-forms guard, so
439
+ * it composes harmlessly with bracket params.
440
+ *
441
+ * Returns `{ metadata?, error? }`. When `error` is set the caller returns it
442
+ * directly (already shaped as a 400 with `error` + `code`).
443
+ */
444
+ function parseMetadataJsonAlias(url: URL): {
445
+ metadata?: Record<string, unknown>;
446
+ error?: Response;
447
+ } {
448
+ const raw = parseQuery(url, "metadata");
449
+ if (raw === null) return {};
450
+
451
+ const malformed = (detail: string): Response =>
452
+ json(
453
+ {
454
+ error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
455
+ code: "INVALID_QUERY",
456
+ },
457
+ 400,
458
+ );
459
+
460
+ let parsed: unknown;
461
+ try {
462
+ parsed = JSON.parse(raw);
463
+ } catch (e) {
464
+ return { error: malformed(`failed to parse: ${e instanceof Error ? e.message : String(e)}`) };
465
+ }
466
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
467
+ const got = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
468
+ return { error: malformed(`got ${got}`) };
469
+ }
470
+ // Empty object → no filter intent. Return absent so it neither sets a
471
+ // metadata filter nor trips the both-forms guard in the handler.
472
+ if (Object.keys(parsed).length === 0) return {};
473
+ return { metadata: parsed as Record<string, unknown> };
474
+ }
475
+
476
+ /**
477
+ * Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
478
+ * Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
479
+ * search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
480
+ * enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
481
+ * validated identically to the structured path.
482
+ *
483
+ * Returns `{ expand }` (undefined when absent/empty → store defaults to
484
+ * "subtypes") or `{ error }` (a 400 Response) on an unknown value.
485
+ */
486
+ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
487
+ const expandParam = parseQuery(url, "expand");
488
+ if (expandParam === null || expandParam === "") return {};
489
+ if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
490
+ return {
491
+ error: json(
492
+ {
493
+ error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
494
+ code: "INVALID_QUERY",
495
+ },
496
+ 400,
497
+ ),
498
+ };
499
+ }
500
+ return { expand: expandParam as TagExpandMode };
501
+ }
502
+
503
+ /**
504
+ * Parse the shared notes-query parameters (tags / excludeTags / path /
505
+ * pathPrefix / extension / metadata filters / date filters / sort / paging /
506
+ * cursor) into a `QueryOpts`, plus flags for the query shapes that the live
507
+ * subscription endpoint must reject (`search`, `near`, `cursor`).
508
+ *
509
+ * Factored out of `handleNotesInner`'s structured-query branch so the
510
+ * `/subscribe` route evaluates the SAME predicate the snapshot query does —
511
+ * predicate parity by construction, not copy-paste. `handleNotesInner` keeps
512
+ * its own inline parsing for the single-note (`id`) and full-text (`search`)
513
+ * branches; this helper covers the structured-query shape both endpoints share.
514
+ *
515
+ * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
516
+ * as the inline code did. `hasSearch` is surfaced from the raw `search` param
517
+ * (this helper does not itself build a search query — the caller routes that).
518
+ */
519
+ export function parseNotesQueryOpts(url: URL): {
520
+ queryOpts?: QueryOpts;
521
+ hasSearch: boolean;
522
+ hasNear: boolean;
523
+ hasCursor: boolean;
524
+ error?: Response;
525
+ } {
526
+ const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
527
+ const hasNear = parseQuery(url, "near[note_id]") !== null;
528
+ const cursorParam = parseQuery(url, "cursor");
529
+ const hasCursor = cursorParam !== null && cursorParam !== "";
530
+
531
+ const tags = parseQueryList(url, "tag");
532
+ const bracket = parseMetaBrackets(url);
533
+ if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
534
+ const metadataAlias = parseMetadataJsonAlias(url);
535
+ if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
536
+ if (metadataAlias.metadata && bracket.metadata) {
537
+ return {
538
+ hasSearch,
539
+ hasNear,
540
+ hasCursor,
541
+ error: json(
542
+ {
543
+ error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
544
+ code: "INVALID_QUERY",
545
+ },
546
+ 400,
547
+ ),
548
+ };
549
+ }
550
+
551
+ // Tag-expansion axis (vault tag `expand` axis). Optional; absent →
552
+ // "subtypes" (resolved at the store, kept undefined here so it stays
553
+ // byte-identical to pre-axis behavior). Unknown value → 400 via the shared
554
+ // helper (same shape the search branch uses).
555
+ const expandParsed = parseExpandParam(url);
556
+ if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
557
+ const expand = expandParsed.expand;
558
+
559
+ const queryOpts: QueryOpts = {
560
+ tags,
561
+ tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
562
+ expand,
563
+ excludeTags: parseQueryList(url, "exclude_tag"),
564
+ hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
565
+ hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
566
+ path: parseQuery(url, "path") ?? undefined,
567
+ pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
568
+ extension: parseExtensionFilter(url),
569
+ metadata: bracket.metadata ?? metadataAlias.metadata,
570
+ ...(bracket.dateFilter
571
+ ? { dateFilter: bracket.dateFilter }
572
+ : parseQuery(url, "date_field")
573
+ ? {
574
+ dateFilter: {
575
+ field: parseQuery(url, "date_field")!,
576
+ from: parseQuery(url, "date_from") ?? undefined,
577
+ to: parseQuery(url, "date_to") ?? undefined,
578
+ },
579
+ }
580
+ : {
581
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
582
+ dateTo: parseQuery(url, "date_to") ?? undefined,
583
+ }),
584
+ sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
585
+ orderBy: parseQuery(url, "order_by") ?? undefined,
586
+ limit: parseInt10(parseQuery(url, "limit")) ?? 50,
587
+ offset: parseInt10(parseQuery(url, "offset")),
588
+ cursor: cursorParam ?? undefined,
589
+ };
590
+
591
+ return { queryOpts, hasSearch, hasNear, hasCursor };
592
+ }
593
+
356
594
  /**
357
595
  * Parse include_metadata query param.
358
596
  * - absent/null → undefined (all metadata, default)
@@ -373,10 +611,20 @@ function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
373
611
  /**
374
612
  * Parse expand_links/expand_depth/expand_mode from query params, returning
375
613
  * an (ExpandContext, depth) pair if expansion is requested, else null.
614
+ *
615
+ * `tagScope` (security review): when the caller is tag-scoped, an `isVisible`
616
+ * predicate is built from the SAME tag-scope allowlist the rest of the
617
+ * handler uses and injected into the expand context. The expander then
618
+ * leaves any `[[wikilink]]` whose target is out of scope UNRESOLVED — so
619
+ * `expand_links=true` can never inline out-of-scope note content (and the
620
+ * out-of-scope case is byte-indistinguishable from a missing target). For
621
+ * an unscoped token the predicate is `undefined` and expansion behaves
622
+ * exactly as before.
376
623
  */
377
624
  function parseExpandParams(
378
625
  url: URL,
379
626
  db: any,
627
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
380
628
  ): { ctx: ExpandContext; depth: number } | null {
381
629
  if (!parseBool(parseQuery(url, "expand_links"), false)) return null;
382
630
  const modeRaw = parseQuery(url, "expand_mode");
@@ -388,7 +636,8 @@ function parseExpandParams(
388
636
  MAX_EXPAND_DEPTH,
389
637
  ),
390
638
  );
391
- return { ctx: { db, mode, expanded: new Set() }, depth };
639
+ const isVisible = buildExpandVisibility(tagScope.allowed, tagScope.raw);
640
+ return { ctx: { db, mode, expanded: new Set(), ...(isVisible ? { isVisible } : {}) }, depth };
392
641
  }
393
642
 
394
643
 
@@ -474,7 +723,7 @@ async function handleNotesInner(
474
723
  ): Promise<Response> {
475
724
  const url = new URL(req.url);
476
725
  const method = req.method;
477
- const db = (store as any).db;
726
+ const db = store.db;
478
727
 
479
728
  // ---- Collection routes (no ID in path) ----
480
729
  if (subpath === "") {
@@ -494,19 +743,37 @@ async function handleNotesInner(
494
743
  return json({ error: "Note not found", id }, 404);
495
744
  }
496
745
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
746
+ const contentRange = parseContentRangeQuery(url, includeContent);
747
+ if (contentRange.error) return contentRange.error;
497
748
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
498
- const expand = parseExpandParams(url, db);
749
+ const expand = parseExpandParams(url, db, tagScope);
499
750
  if (expand && includeContent && typeof result.content === "string") {
500
751
  expand.ctx.expanded.add(note.id);
501
752
  result.content = expandContent(result.content, expand.ctx, expand.depth);
502
753
  }
754
+ // Content range applies to the FINAL returned content (post-
755
+ // expansion) — the window the client pages through is the same
756
+ // document it would have received unpaged.
757
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
503
758
  result = filterMetadata(result, parseIncludeMetadata(url));
504
759
  if (parseBool(parseQuery(url, "include_links"), false)) {
505
- result.links = linkOps.getLinksHydrated(db, note.id);
760
+ // Tag-scope: drop links whose neighbor is out of scope so the
761
+ // hydrated sourceNote/targetNote summaries can't leak out-of-scope
762
+ // ids/paths/tags. No-op for unscoped tokens.
763
+ result.links = filterHydratedLinksByTagScope(
764
+ linkOps.getLinksHydrated(db, note.id),
765
+ tagScope.allowed,
766
+ tagScope.raw,
767
+ );
506
768
  }
507
769
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
508
770
  result.attachments = await store.getAttachments(note.id);
509
771
  }
772
+ // linkCount injected after filterMetadata on purpose — same as
773
+ // links/attachments above; filterMetadata only touches `metadata`.
774
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
775
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
776
+ }
510
777
  return json(result);
511
778
  }
512
779
 
@@ -529,15 +796,27 @@ async function handleNotesInner(
529
796
  if (search) {
530
797
  const searchTags = parseQueryList(url, "tag");
531
798
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
532
- const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
799
+ // Tag-expansion axis (vault tag `expand` axis). This branch bypasses
800
+ // `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
801
+ // `GET /notes?search=x&expand=bogus` would silently ignore the bad
802
+ // value. The validated mode is threaded into the search tag-narrowing.
803
+ const tagExpand = parseExpandParam(url);
804
+ if (tagExpand.error) return tagExpand.error;
805
+ const rawResults = await store.searchNotes(search, {
806
+ tags: searchTags,
807
+ limit,
808
+ expand: tagExpand.expand,
809
+ });
533
810
  // Tag-scope: drop any result the token isn't permitted to see. Filter
534
811
  // happens after the store query so an empty post-filter list still
535
812
  // returns 200 [] (consistent with "no matches"), not 403.
536
813
  const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
537
814
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
815
+ const contentRange = parseContentRangeQuery(url, includeContent);
816
+ if (contentRange.error) return contentRange.error;
538
817
  const inclMeta = parseIncludeMetadata(url);
539
818
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
540
- const expand = parseExpandParams(url, db);
819
+ const expand = parseExpandParams(url, db, tagScope);
541
820
  if (expand && includeContent) {
542
821
  for (const n of output) expand.ctx.expanded.add(n.id);
543
822
  for (const n of output) {
@@ -546,9 +825,25 @@ async function handleNotesInner(
546
825
  }
547
826
  }
548
827
  }
828
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
829
+ if (contentRange.range && includeContent) {
830
+ for (const n of output) applyContentRange(n, contentRange.range);
831
+ }
549
832
  if (inclMeta !== undefined && inclMeta !== true) {
550
833
  output = output.map((n: any) => filterMetadata(n, inclMeta));
551
834
  }
835
+ // Opt-in link degree (vault feedback #4) — one batch count, same as
836
+ // the structured-query path. Runs AFTER filterMetadata on purpose
837
+ // (filterMetadata only touches `metadata`, so linkCount survives —
838
+ // don't casually swap the order).
839
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
840
+ const counts = linkOps.getLinkCounts(
841
+ db,
842
+ output.map((n: any) => n.id),
843
+ parseLinkCountDirection(url),
844
+ );
845
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
846
+ }
552
847
  return json(output);
553
848
  }
554
849
 
@@ -564,7 +859,7 @@ async function handleNotesInner(
564
859
  //
565
860
  // - **Flat date params** (DEPRECATED): `?date_field=created_at&
566
861
  // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
567
- // Still functional through 0.5.x; planned removal in 0.6.0
862
+ // Still functional through 0.5.x; planned removal in a later 0.x
568
863
  // (vault#288). New consumers should use bracket-style.
569
864
  //
570
865
  // Precedence on overlap: bracket-style wins. If a caller passes both
@@ -577,15 +872,16 @@ async function handleNotesInner(
577
872
  // Surface asymmetry: REST flattens to a query string; MCP takes a
578
873
  // nested `date_filter: { field, from, to }` object directly. Both
579
874
  // lower to the same store-level `dateFilter` shape.
580
- const tags = parseQueryList(url, "tag");
581
- const bracket = parseMetaBrackets(url);
582
- if (bracket.error) return bracket.error;
583
- // Opaque cursor for "since last checked" agent loops (vault#313).
584
- // When present, switches the response shape to {notes, next_cursor}
585
- // and routes through queryNotesPaged for keyset pagination. Mutually
586
- // exclusive with the `near` graph-neighborhood scope (rebuilding the
587
- // neighborhood per page isn't stable) — rejected below.
875
+ // Structured-query parsing is shared with the live `/subscribe` route
876
+ // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
877
+ // string to the same `QueryOpts` — predicate parity by construction.
878
+ const parsed = parseNotesQueryOpts(url);
879
+ if (parsed.error) return parsed.error;
880
+ const queryOpts = parsed.queryOpts!;
588
881
  const cursorParam = parseQuery(url, "cursor");
882
+ // Opaque cursor for "since last checked" agent loops (vault#313) is
883
+ // mutually exclusive with the `near` graph-neighborhood scope (rebuilding
884
+ // the neighborhood per page isn't stable).
589
885
  const nearNoteIdEarly = parseQuery(url, "near[note_id]");
590
886
  if (cursorParam && nearNoteIdEarly) {
591
887
  return json(
@@ -598,48 +894,6 @@ async function handleNotesInner(
598
894
  }
599
895
  let results: Note[];
600
896
  let nextCursor: string | null = null;
601
- const queryOpts = {
602
- tags,
603
- tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
604
- excludeTags: parseQueryList(url, "exclude_tag"),
605
- hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
606
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
607
- path: parseQuery(url, "path") ?? undefined,
608
- pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
609
- // Extension filter (vault#328). Accepts repeated `extension=`
610
- // params for the array form: `?extension=csv&extension=yaml`.
611
- // `parseQueryList` already returns undefined when no params
612
- // are present, so the filter is silently skipped on a plain
613
- // GET without the extension query.
614
- extension: parseExtensionFilter(url),
615
- metadata: bracket.metadata,
616
- // Date-range precedence chain (highest to lowest):
617
- // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
618
- // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
619
- // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
620
- // — filters on `n.created_at` by definition.
621
- // The engine rejects combinations of `dateFilter` with the legacy
622
- // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
623
- ...(bracket.dateFilter
624
- ? { dateFilter: bracket.dateFilter }
625
- : parseQuery(url, "date_field")
626
- ? {
627
- dateFilter: {
628
- field: parseQuery(url, "date_field")!,
629
- from: parseQuery(url, "date_from") ?? undefined,
630
- to: parseQuery(url, "date_to") ?? undefined,
631
- },
632
- }
633
- : {
634
- dateFrom: parseQuery(url, "date_from") ?? undefined,
635
- dateTo: parseQuery(url, "date_to") ?? undefined,
636
- }),
637
- sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
638
- orderBy: parseQuery(url, "order_by") ?? undefined,
639
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
640
- offset: parseInt10(parseQuery(url, "offset")),
641
- cursor: cursorParam ?? undefined,
642
- };
643
897
  try {
644
898
  if (cursorParam) {
645
899
  const page = await store.queryNotesPaged(queryOpts);
@@ -677,7 +931,24 @@ async function handleNotesInner(
677
931
  }
678
932
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
679
933
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
680
- const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
934
+ // Tag-scope policy for `near[]` (vault#439 hop-guard, symmetric with
935
+ // find-path): for a tag-scoped token the BFS refuses to traverse
936
+ // THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
937
+ // scoped to ["work"] can't reach an in-scope note at depth 2 via a
938
+ // #personal intermediary at depth 1; that note is simply unreachable.
939
+ // The `filterNotesByTagScope` pass below still runs (defense in depth),
940
+ // but the wall makes it redundant for the `near[]` result set.
941
+ // Unscoped tokens (`tagScope.raw === null`) install no predicate → the
942
+ // FULL graph is walked exactly as before, behavior unchanged.
943
+ const isTraversable = tagScope.raw
944
+ ? (id: string) =>
945
+ noteWithinTagScope(
946
+ { id, tags: getNoteTags(db, id) } as Note,
947
+ tagScope.allowed,
948
+ tagScope.raw,
949
+ )
950
+ : undefined;
951
+ const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
681
952
  const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
682
953
  results = results.filter((n) => nearScope.has(n.id));
683
954
  }
@@ -688,11 +959,14 @@ async function handleNotesInner(
688
959
  results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
689
960
 
690
961
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
962
+ const contentRange = parseContentRangeQuery(url, includeContent);
963
+ if (contentRange.error) return contentRange.error;
691
964
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
692
965
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
966
+ const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
693
967
  const inclMeta = parseIncludeMetadata(url);
694
968
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
695
- const expand = parseExpandParams(url, db);
969
+ const expand = parseExpandParams(url, db, tagScope);
696
970
  if (expand && includeContent) {
697
971
  for (const n of output) expand.ctx.expanded.add(n.id);
698
972
  for (const n of output) {
@@ -701,9 +975,31 @@ async function handleNotesInner(
701
975
  }
702
976
  }
703
977
  }
978
+ // Content range — per-note, post-expansion (see core/src/content-range.ts).
979
+ if (contentRange.range && includeContent) {
980
+ for (const n of output) applyContentRange(n, contentRange.range);
981
+ }
704
982
  if (inclMeta !== undefined && inclMeta !== true) {
705
983
  output = output.map((n: any) => filterMetadata(n, inclMeta));
706
984
  }
985
+ // Opt-in link degree (vault feedback #4). ONE batch count over all
986
+ // result ids — NOT a per-note query — so the field stays O(2 index
987
+ // scans) per request regardless of page size. Mutates `output` in
988
+ // place; injected on the same objects the enrichment loop below
989
+ // touches, the same way `links`/`attachments` are surfaced.
990
+ // Ordering: this runs AFTER the filterMetadata pass above on purpose —
991
+ // filterMetadata only touches the `metadata` key, so a linkCount
992
+ // injected here survives. Don't casually swap the order; injecting
993
+ // before filterMetadata would still survive today but couples the two
994
+ // to filterMetadata's current narrow behavior.
995
+ if (includeLinkCount) {
996
+ const counts = linkOps.getLinkCounts(
997
+ db,
998
+ output.map((n: any) => n.id),
999
+ parseLinkCountDirection(url),
1000
+ );
1001
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
1002
+ }
707
1003
 
708
1004
  // Graph format — reshape into { nodes, edges }
709
1005
  if (parseQuery(url, "format") === "graph") {
@@ -711,8 +1007,9 @@ async function handleNotesInner(
711
1007
  const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
712
1008
  const edges: { source: string; target: string; relationship: string }[] = [];
713
1009
  if (includeLinks) {
1010
+ const linksByNote = linkOps.getLinksHydratedForNotes(db, results.map((n) => n.id));
714
1011
  for (const n of results) {
715
- for (const link of linkOps.getLinksHydrated(db, n.id)) {
1012
+ for (const link of linksByNote.get(n.id) ?? []) {
716
1013
  // Only include edges where source is this note and target is in the result set
717
1014
  if (link.sourceId === n.id && resultIds.has(link.targetId)) {
718
1015
  edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
@@ -724,10 +1021,23 @@ async function handleNotesInner(
724
1021
  }
725
1022
 
726
1023
  if (includeLinks || includeAttachments) {
1024
+ // Whole-page link hydration in a constant number of queries — the
1025
+ // per-note variant cost (1 link query + 1 summary query + N tag
1026
+ // queries) × page size. 2026-06-10 perf measurements.
1027
+ const linksByNote = includeLinks
1028
+ ? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
1029
+ : null;
727
1030
  const enrichedOut: any[] = [];
728
1031
  for (const n of output) {
729
1032
  const enriched: any = { ...n };
730
- if (includeLinks) enriched.links = linkOps.getLinksHydrated(db, n.id);
1033
+ if (linksByNote) {
1034
+ // Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
1035
+ enriched.links = filterHydratedLinksByTagScope(
1036
+ linksByNote.get(n.id) ?? [],
1037
+ tagScope.allowed,
1038
+ tagScope.raw,
1039
+ );
1040
+ }
731
1041
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
732
1042
  enrichedOut.push(enriched);
733
1043
  }
@@ -830,19 +1140,33 @@ async function handleNotesInner(
830
1140
  throw e;
831
1141
  }
832
1142
 
833
- // Apply tag schema defaults
1143
+ // Apply tag schema defaults, then re-read the notes whose metadata was
1144
+ // actually default-filled so the response reflects the final on-disk
1145
+ // state (the `created` entries were read before `applySchemaDefaults`
1146
+ // ran). Mirrors the MCP create-note path (vault#316). Batched re-read
1147
+ // (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
1148
+ // applied so the common no-defaults path adds zero extra reads.
1149
+ const mutatedIds = new Set<string>();
834
1150
  for (const note of created) {
835
1151
  if (note.tags?.length) {
836
- await applySchemaDefaults(store, db, [note.id], note.tags);
1152
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1153
+ mutatedIds.add(id);
1154
+ }
837
1155
  }
838
1156
  }
1157
+ const refreshed =
1158
+ mutatedIds.size === 0
1159
+ ? created
1160
+ : (() => {
1161
+ const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
1162
+ return created.map((n) => byId.get(n.id) ?? n);
1163
+ })();
839
1164
 
840
1165
  // Attach `validation_status` so HTTP create-note matches the MCP
841
- // surface (vault#287). Mirrors the MCP create-note attach site at
842
- // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
1166
+ // surface (vault#287). `attachValidationStatus` returns the note
843
1167
  // unchanged when no tag declares fields, so vaults without any tag
844
1168
  // schemas see no behavior change.
845
- const final = created.map((n) => attachValidationStatus(store, db, n));
1169
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
846
1170
  return json(body.notes ? final : final[0], 201);
847
1171
  }
848
1172
 
@@ -882,7 +1206,14 @@ async function handleNotesInner(
882
1206
  // Explicit `transcribe: true` wins — if the caller asked, we honor that
883
1207
  // regardless of the auto-transcribe toggle (back-compat).
884
1208
  const explicitOptIn = body.transcribe === true;
885
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType);
1209
+ // Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
1210
+ // (vault.yaml) and pass it as the precedence-winning toggle. A vault that
1211
+ // set its own value uses it; one that left it unset falls through to the
1212
+ // global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
1213
+ const perVaultEnabled = vault
1214
+ ? readVaultConfig(vault)?.auto_transcribe?.enabled
1215
+ : undefined;
1216
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
886
1217
  const attMeta = (explicitOptIn || autoOptIn)
887
1218
  ? {
888
1219
  transcribe_status: "pending" as const,
@@ -942,22 +1273,26 @@ async function handleNotesInner(
942
1273
  return json({ error: "Method not allowed" }, 405);
943
1274
  }
944
1275
 
945
- // POST /notes/:idOrPath/retry-transcription — vault#353 design Q5.
1276
+ // POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
946
1277
  //
947
- // Re-runs the auto-transcribe pipeline against the original audio
948
- // attachment recorded in the transcript note's `transcript_attachment_id`
949
- // frontmatter. Only valid on transcript notes (the target idOrPath must
950
- // be a transcript note with `transcript_status: "failed"`); calling on
951
- // anything else returns 400 with a clear reason.
1278
+ // Re-runs transcription against a failed audio attachment. Two target
1279
+ // shapes, dispatched in handleRetryTranscription by whether the note carries
1280
+ // `transcript_status` frontmatter:
1281
+ // - Auto-flow (vault#353): target is a `<audio>.transcript.md` note with
1282
+ // `transcript_status: "failed"`; audio located via
1283
+ // `transcript_attachment_id`, origin preserved as "auto".
1284
+ // - Legacy in-body memo (finding F): target is the memo note itself (no
1285
+ // `transcript_status`); finds its own failed attachment, resets it
1286
+ // preserving `transcribe_origin: "legacy"`, and re-arms `transcribe_stub`.
952
1287
  //
953
1288
  // Wire shape:
954
1289
  // POST .../notes/<idOrPath>/retry-transcription
955
- // → 202 { attachment_id, transcript_path } when re-enqueued
956
- // 400 invalid_target (not a transcript note)
957
- // 400 not_failed (transcript already succeeded; nothing to retry)
958
- // 404 attachment_missing (transcript_attachment_id row deleted)
1290
+ // → 202 { attachment_id, attachment_path, transcript_note_id, worker }
1291
+ // 400 not_failed (auto-flow: transcript already succeeded)
1292
+ // 400 missing_attachment_id (auto-flow: transcript_attachment_id absent)
1293
+ // 400 no_failed_attachment (legacy: no failed audio attachment to retry)
1294
+ // 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
959
1295
  // 404 audio_missing (audio file unlinked from disk)
960
- // 503 scribe_unavailable (no worker configured this boot)
961
1296
  if (sub === "/retry-transcription") {
962
1297
  if (method !== "POST") return json({ error: "Method not allowed" }, 405);
963
1298
  if (!vault) return json({ error: "Vault context required" }, 400);
@@ -979,19 +1314,31 @@ async function handleNotesInner(
979
1314
  return json({ error: "Not found" }, 404);
980
1315
  }
981
1316
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
1317
+ const contentRange = parseContentRangeQuery(url, includeContent);
1318
+ if (contentRange.error) return contentRange.error;
982
1319
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
983
- const expand = parseExpandParams(url, db);
1320
+ const expand = parseExpandParams(url, db, tagScope);
984
1321
  if (expand && includeContent && typeof result.content === "string") {
985
1322
  expand.ctx.expanded.add(note.id);
986
1323
  result.content = expandContent(result.content, expand.ctx, expand.depth);
987
1324
  }
1325
+ // Content range applies to the FINAL returned content (post-expansion).
1326
+ if (contentRange.range && includeContent) applyContentRange(result, contentRange.range);
988
1327
  result = filterMetadata(result, parseIncludeMetadata(url));
989
1328
  if (parseBool(parseQuery(url, "include_links"), false)) {
990
- result.links = linkOps.getLinksHydrated(db, note.id);
1329
+ // Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
1330
+ result.links = filterHydratedLinksByTagScope(
1331
+ linkOps.getLinksHydrated(db, note.id),
1332
+ tagScope.allowed,
1333
+ tagScope.raw,
1334
+ );
991
1335
  }
992
1336
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
993
1337
  result.attachments = await store.getAttachments(note.id);
994
1338
  }
1339
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
1340
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
1341
+ }
995
1342
  return json(result);
996
1343
  }
997
1344
 
@@ -1256,7 +1603,27 @@ async function handleNotesInner(
1256
1603
  // `toNoteIndex` drops unknown fields).
1257
1604
  const updatedNote = await store.getNote(note.id);
1258
1605
  if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1259
- const validated = attachValidationStatus(store, db, updatedNote);
1606
+ const validated: any = attachValidationStatus(store, db, updatedNote);
1607
+ // Echo hydrated links when a link mutation was part of this request,
1608
+ // OR the caller explicitly asked for them via `?include_links=true`
1609
+ // (vault feedback #8). Previously the update response omitted links
1610
+ // entirely (`getNote` populates tags but not links), forcing callers
1611
+ // to re-GET with `?include_links=true` just to confirm a link they
1612
+ // had just added/removed. Additive field, scoped to UPDATE: present
1613
+ // only when mutated or requested. Mirrors the GET / query-notes
1614
+ // hydration call form exactly (`linkOps.getLinksHydrated`).
1615
+ const linkMutated = body.links?.add !== undefined || body.links?.remove !== undefined;
1616
+ const includeLinksResp = linkMutated || parseBool(parseQuery(url, "include_links"), false);
1617
+ if (includeLinksResp) {
1618
+ // Tag-scope: strip out-of-scope-neighbor links from the echoed set
1619
+ // (no-op unscoped). A write token tag-scoped to #work mustn't learn
1620
+ // about a #personal note it happened to link to.
1621
+ validated.links = filterHydratedLinksByTagScope(
1622
+ linkOps.getLinksHydrated(db, updatedNote.id),
1623
+ tagScope.allowed,
1624
+ tagScope.raw,
1625
+ );
1626
+ }
1260
1627
  const includeContentResp = body.include_content !== false;
1261
1628
  // `created: false` is appended to every update-path response so
1262
1629
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -1266,6 +1633,9 @@ async function handleNotesInner(
1266
1633
  const lean: any = toNoteIndex(validated);
1267
1634
  const vs = (validated as any).validation_status;
1268
1635
  if (vs !== undefined) lean.validation_status = vs;
1636
+ // Carry the link echo across the lean conversion — `toNoteIndex`
1637
+ // drops unknown fields, same as the `validation_status` recipe above.
1638
+ if (validated.links !== undefined) lean.links = validated.links;
1269
1639
  lean.created = false;
1270
1640
  return json(lean);
1271
1641
  } catch (e: any) {
@@ -1418,7 +1788,7 @@ export async function handleTags(
1418
1788
  // that token's allowlist. Aggregate matches across sources for a single
1419
1789
  // 409 envelope.
1420
1790
  const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
1421
- const db = (store as any).db;
1791
+ const db = store.db;
1422
1792
  for (const src of sources) {
1423
1793
  const tokens = findTokensReferencingTag(db, src as string);
1424
1794
  if (tokens.length > 0) referenced.push({ source: src as string, tokens });
@@ -1513,10 +1883,12 @@ export async function handleTags(
1513
1883
  parent_names?: unknown;
1514
1884
  };
1515
1885
 
1516
- // Validate relationships shape + cardinality vocabulary up front so
1517
- // a bad payload returns 400, not a thrown 500.
1886
+ // Validate the relationships payload up front so a bad payload returns
1887
+ // 400, not a thrown 500. `relationships` is an opaque vocabulary map
1888
+ // (relationship-name → arbitrary JSON the app interprets); we only check
1889
+ // that it's a JSON object (a map), then persist verbatim.
1518
1890
  let relationshipsPatch:
1519
- | Record<string, tagSchemaOps.TagRelationship>
1891
+ | tagSchemaOps.TagRelationshipMap
1520
1892
  | null
1521
1893
  | undefined;
1522
1894
  if (body.relationships === null) {
@@ -1580,7 +1952,7 @@ export async function handleTags(
1580
1952
  // tag would silently orphan the token's allowlist. Fail closed (409)
1581
1953
  // and name the offending tokens so the operator can revoke or re-mint
1582
1954
  // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1583
- const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1955
+ const referenced_by = findTokensReferencingTag(store.db, tagName);
1584
1956
  if (referenced_by.length > 0) {
1585
1957
  return json(
1586
1958
  {
@@ -1615,7 +1987,7 @@ export async function handleFindPath(
1615
1987
  const target = parseQuery(url, "target");
1616
1988
  if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
1617
1989
 
1618
- const db = (store as any).db;
1990
+ const db = store.db;
1619
1991
  try {
1620
1992
  const sourceNote = await resolveNote(store, source);
1621
1993
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
@@ -1661,16 +2033,36 @@ type VaultConfigLike = {
1661
2033
  name: string;
1662
2034
  description?: string;
1663
2035
  audio_retention?: "keep" | "until_transcribed" | "never";
2036
+ auto_transcribe?: { enabled?: boolean };
1664
2037
  };
1665
2038
 
1666
2039
  const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
1667
2040
 
2041
+ /**
2042
+ * Resolve the effective auto-transcribe toggle for a vault's GET response, the
2043
+ * SAME resolution `shouldAutoTranscribe` uses at the decision point:
2044
+ * **per-vault → global → true**. A vault that set its own `auto_transcribe`
2045
+ * shows that; one that left it unset shows the server-wide default (itself
2046
+ * default-ON). This keeps the GET in lock-step with what the worker actually
2047
+ * does, with no field-name drift.
2048
+ */
2049
+ function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
2050
+ return (
2051
+ vaultConfig.auto_transcribe?.enabled
2052
+ ?? readGlobalConfig().auto_transcribe?.enabled
2053
+ ?? true
2054
+ );
2055
+ }
2056
+
1668
2057
  function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
1669
2058
  return {
1670
2059
  name: vaultConfig.name,
1671
2060
  description: vaultConfig.description ?? null,
1672
2061
  config: {
1673
2062
  audio_retention: vaultConfig.audio_retention ?? "keep",
2063
+ auto_transcribe: {
2064
+ enabled: resolveAutoTranscribeEnabled(vaultConfig),
2065
+ },
1674
2066
  },
1675
2067
  };
1676
2068
  }
@@ -1694,7 +2086,7 @@ export async function handleVault(
1694
2086
  if (req.method === "PATCH") {
1695
2087
  const body = await req.json() as {
1696
2088
  description?: string;
1697
- config?: { audio_retention?: string };
2089
+ config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
1698
2090
  };
1699
2091
  let dirty = false;
1700
2092
 
@@ -1718,6 +2110,28 @@ export async function handleVault(
1718
2110
  dirty = true;
1719
2111
  }
1720
2112
 
2113
+ // auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
2114
+ // vault.yaml (via `persist`, the same writeVaultConfig path as
2115
+ // description/audio_retention). Flipping it for vault X affects only X;
2116
+ // scribe's "link to vault X" PATCHes this and never touches other vaults.
2117
+ // The worker reads the same per-vault field (per-vault → global → true).
2118
+ // Validate the shape: when `auto_transcribe` is present it must carry a
2119
+ // boolean `enabled`.
2120
+ if (body.config?.auto_transcribe !== undefined) {
2121
+ const enabled = body.config.auto_transcribe?.enabled;
2122
+ if (typeof enabled !== "boolean") {
2123
+ return json(
2124
+ {
2125
+ error: "invalid_auto_transcribe",
2126
+ message: "auto_transcribe.enabled must be a boolean",
2127
+ },
2128
+ 400,
2129
+ );
2130
+ }
2131
+ vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
2132
+ dirty = true;
2133
+ }
2134
+
1721
2135
  if (dirty && persist) persist();
1722
2136
  return json(vaultResponse(vaultConfig));
1723
2137
  }
@@ -1729,12 +2143,32 @@ export async function handleVault(
1729
2143
  // Unresolved wikilinks — REST-only (admin/maintenance)
1730
2144
  // ---------------------------------------------------------------------------
1731
2145
 
1732
- export function handleUnresolvedWikilinks(req: Request, store: Store): Response {
2146
+ export function handleUnresolvedWikilinks(
2147
+ req: Request,
2148
+ store: Store,
2149
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
2150
+ ): Response {
1733
2151
  const url = new URL(req.url);
1734
2152
  const limitStr = url.searchParams.get("limit");
1735
2153
  const limit = limitStr ? parseInt(limitStr, 10) : 50;
1736
- const db = (store as any).db;
1737
- return Response.json(listUnresolvedWikilinks(db, limit));
2154
+ const db = store.db;
2155
+ const result = listUnresolvedWikilinks(db, limit);
2156
+
2157
+ // Unscoped token → return as-is (unchanged behavior).
2158
+ if (tagScope.raw === null) return Response.json(result);
2159
+
2160
+ // Tag-scope confidentiality (security review): each unresolved row carries
2161
+ // a `source_id` (+ `source_path`) plus the raw `target_path` wikilink
2162
+ // string. For a tag-scoped token, surface ONLY rows whose SOURCE note is
2163
+ // within the token's tag scope — otherwise we'd leak out-of-scope note IDs
2164
+ // and the wikilink target strings those notes contain. Filter the page and
2165
+ // recompute `count` from the filtered set so the aggregate total of
2166
+ // out-of-scope rows doesn't leak either.
2167
+ const filtered = result.unresolved.filter((row) => {
2168
+ const note = getNote(db, row.source_id);
2169
+ return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
2170
+ });
2171
+ return Response.json({ unresolved: filtered, count: filtered.length });
1738
2172
  }
1739
2173
 
1740
2174
  // ---------------------------------------------------------------------------
@@ -1923,21 +2357,35 @@ ${rendered}
1923
2357
  // ---------------------------------------------------------------------------
1924
2358
 
1925
2359
  /**
1926
- * Re-enqueue the original audio attachment for a `transcript_status: failed`
1927
- * transcript note. Steps:
2360
+ * Re-enqueue the original audio attachment for a failed transcription.
1928
2361
  *
1929
- * 1. Validate target is a transcript note (`transcript_status` set in
1930
- * metadata) AND that status is `failed`.
1931
- * 2. Find the original audio attachment by id from
1932
- * `transcript_attachment_id` frontmatter. 404 if the row's gone.
1933
- * 3. Validate the audio file still exists on disk (retention=keep is
1934
- * assumed by the retry contract; retention=until_transcribed unlinks
1935
- * only on success, retention=never unlinks on failure that last one
1936
- * explicitly breaks retry, by design).
1937
- * 4. Reset `transcribe_status = "pending"`, clear backoff + error fields.
1938
- * The auto-origin marker is preserved so the worker writes a transcript
1939
- * note (overwriting this one in place).
1940
- * 5. Kick the worker if registered; otherwise the sweep picks it up.
2362
+ * Two target shapes are accepted:
2363
+ *
2364
+ * - **Auto-flow (vault#353):** the target is a `<audio>.transcript.md` note
2365
+ * carrying `transcript_status` frontmatter. Requires that status be
2366
+ * `failed`; locates the audio via `transcript_attachment_id`; preserves
2367
+ * `transcribe_origin: "auto"` so a retried success overwrites this
2368
+ * transcript note in place. Behavior is byte-identical to the original
2369
+ * vault#353 contract.
2370
+ *
2371
+ * - **Legacy in-body memo (finding F):** the target is the memo note
2372
+ * itself no `transcript_status` frontmatter. The original capture body
2373
+ * holds `![[<audio>]]` + a `_Transcription unavailable._` marker (written
2374
+ * by the worker on terminal failure). We find the note's own failed audio
2375
+ * attachment, reset it to pending **preserving `transcribe_origin:
2376
+ * "legacy"`** (forcing "auto" would switch to the sibling-transcript-note
2377
+ * shape and orphan the in-body embed), and **re-stamp `transcribe_stub:
2378
+ * true`** on the note. The stub re-arm is load-bearing: the legacy success
2379
+ * path early-returns unless `transcribe_stub === true`, so without it the
2380
+ * retried success would never write the transcript back into the body.
2381
+ * On success the worker replaces the `_Transcription unavailable._` marker
2382
+ * with the transcript in place, yielding the same final shape a first-try
2383
+ * success would.
2384
+ *
2385
+ * Common steps for both: validate the audio attachment row exists (404 if
2386
+ * gone) and its file is still on disk (404 if unlinked — e.g. retention=never
2387
+ * already dropped it), reset the transcribe_status fields, then kick the
2388
+ * worker if registered (otherwise the sweep picks it up).
1941
2389
  */
1942
2390
  async function handleRetryTranscription(
1943
2391
  store: Store,
@@ -1945,15 +2393,13 @@ async function handleRetryTranscription(
1945
2393
  vault: string,
1946
2394
  ): Promise<Response> {
1947
2395
  const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
2396
+
2397
+ // Legacy in-body memo: no `transcript_status` frontmatter. The note owns
2398
+ // the failed audio attachment directly; there's no sibling transcript note.
1948
2399
  if (typeof meta.transcript_status !== "string") {
1949
- return json(
1950
- {
1951
- error: "invalid_target",
1952
- message: "Target note is not a transcript note (no transcript_status frontmatter).",
1953
- },
1954
- 400,
1955
- );
2400
+ return handleRetryLegacyInBody(store, note, vault);
1956
2401
  }
2402
+
1957
2403
  if (meta.transcript_status !== "failed") {
1958
2404
  return json(
1959
2405
  {
@@ -2040,13 +2486,171 @@ async function handleRetryTranscription(
2040
2486
  );
2041
2487
  }
2042
2488
 
2489
+ /**
2490
+ * Retry path for a legacy in-body voice memo (finding F). The target note is
2491
+ * the memo itself (no `transcript_status` frontmatter); it directly owns the
2492
+ * audio attachment whose transcription failed.
2493
+ *
2494
+ * Steps:
2495
+ * 1. Find the note's own audio attachment with `transcribe_status ===
2496
+ * "failed"`. 400 `no_failed_attachment` if none — there's nothing to
2497
+ * retry.
2498
+ * 2. Validate the audio file is still on disk (404 `audio_missing`).
2499
+ * 3. Reset the attachment to pending, **preserving `transcribe_origin:
2500
+ * "legacy"`** (never force "auto" — that switches to the sibling-
2501
+ * transcript-note shape and orphans the in-body `![[memo]]` embed).
2502
+ * 4. **Re-stamp `transcribe_stub: true`** on the note. The legacy worker
2503
+ * success path early-returns unless the note carries this flag (it was
2504
+ * cleared when the failure marker was written), so re-arming it is what
2505
+ * lets the retried success replace the `_Transcription unavailable._`
2506
+ * marker with the transcript.
2507
+ * 5. Kick the worker if registered; otherwise the sweep picks it up.
2508
+ */
2509
+ async function handleRetryLegacyInBody(
2510
+ store: Store,
2511
+ note: Note,
2512
+ vault: string,
2513
+ ): Promise<Response> {
2514
+ const attachments = await store.getAttachments(note.id);
2515
+ const failed = attachments.find((a) => {
2516
+ const m = (a.metadata as Record<string, unknown> | undefined) ?? {};
2517
+ return m.transcribe_status === "failed";
2518
+ });
2519
+ if (!failed) {
2520
+ return json(
2521
+ {
2522
+ error: "no_failed_attachment",
2523
+ message:
2524
+ "Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
2525
+ },
2526
+ 400,
2527
+ );
2528
+ }
2529
+
2530
+ // Audio file existence + safety: defense-in-depth against a bad attachment
2531
+ // row pointing outside the vault assets dir. Same guard as the worker.
2532
+ const assetsRoot = assetsDir(vault);
2533
+ const audioFilePath = normalize(join(assetsRoot, failed.path));
2534
+ if (!audioFilePath.startsWith(normalize(assetsRoot)) || !existsSync(audioFilePath)) {
2535
+ return json(
2536
+ {
2537
+ error: "audio_missing",
2538
+ message: `Original audio file at "${failed.path}" no longer exists on disk.`,
2539
+ },
2540
+ 404,
2541
+ );
2542
+ }
2543
+
2544
+ // Reset the attachment back to pending. Preserve `transcribe_origin:
2545
+ // "legacy"` (a default of `undefined` is also legacy, but make it explicit
2546
+ // so a retried row reads unambiguously) — forcing "auto" here would make
2547
+ // the worker materialize a sibling transcript note instead of patching the
2548
+ // in-body embed, orphaning the memo.
2549
+ const attMeta = { ...(failed.metadata ?? {}) } as Record<string, unknown>;
2550
+ attMeta.transcribe_status = "pending";
2551
+ attMeta.transcribe_requested_at = new Date().toISOString();
2552
+ attMeta.transcribe_origin = "legacy";
2553
+ delete attMeta.transcribe_backoff_until;
2554
+ delete attMeta.transcribe_error;
2555
+ delete attMeta.transcribe_error_code;
2556
+ delete attMeta.transcribe_attempts;
2557
+ await store.setAttachmentMetadata(failed.id, attMeta);
2558
+
2559
+ // Re-arm the stub on the note. The worker's legacy success path gates on
2560
+ // `transcribe_stub === true` and CLEARED it when it wrote the failure
2561
+ // marker; without re-stamping it the retried success early-returns and
2562
+ // never writes the transcript back into the body. Use skipUpdatedAt so the
2563
+ // note's modification time still reflects user intent, matching the
2564
+ // worker's own note writes.
2565
+ //
2566
+ // OC-guarded (vault#435): this read-transform-write would otherwise clobber
2567
+ // a user edit landing between `resolveNote` (above) and this write. Thread
2568
+ // `if_updated_at` and retry once on conflict — re-read, re-apply the
2569
+ // metadata-only re-stamp against the fresh note, write with the fresh
2570
+ // precondition. A second conflict surfaces as 409 so the user can retry.
2571
+ // Only the `transcribe_stub` flag is stamped (never content), so re-applying
2572
+ // against the fresh note is always the correct surgical transform.
2573
+ const restampStub = (current: Note): Record<string, unknown> => ({
2574
+ ...((current.metadata as Record<string, unknown> | undefined) ?? {}),
2575
+ transcribe_stub: true,
2576
+ });
2577
+ try {
2578
+ try {
2579
+ await store.updateNote(note.id, {
2580
+ metadata: restampStub(note),
2581
+ skipUpdatedAt: true,
2582
+ if_updated_at: note.updatedAt,
2583
+ });
2584
+ } catch (err: any) {
2585
+ if (!err || err.code !== "CONFLICT") throw err;
2586
+ // Conflict — re-read, re-apply the stub re-stamp, retry once.
2587
+ const fresh = await store.getNote(note.id);
2588
+ if (!fresh) {
2589
+ return json(
2590
+ { error: "note_missing", message: "Target note disappeared during retry." },
2591
+ 404,
2592
+ );
2593
+ }
2594
+ await store.updateNote(fresh.id, {
2595
+ metadata: restampStub(fresh),
2596
+ skipUpdatedAt: true,
2597
+ if_updated_at: fresh.updatedAt,
2598
+ });
2599
+ }
2600
+ } catch (err: any) {
2601
+ if (err && err.code === "CONFLICT") {
2602
+ // Double conflict — the note kept changing under us. It's a user-facing
2603
+ // request; return 409 so the caller can retry against fresh state. The
2604
+ // attachment was already reset to pending above; a successful re-stamp
2605
+ // on the user's next retry (or the next sweep, if they re-arm the stub)
2606
+ // will let the worker patch the transcript in.
2607
+ return json(
2608
+ {
2609
+ error_type: "conflict",
2610
+ error: "conflict",
2611
+ note_id: note.id,
2612
+ current_updated_at: err.current_updated_at ?? null,
2613
+ your_updated_at: err.expected_updated_at,
2614
+ // Mirror the standard PATCH 409 shape (see the notes-update handler
2615
+ // above) — both `your_updated_at` and `expected_updated_at` carry the
2616
+ // same value, kept for shape-congruence with existing callers.
2617
+ expected_updated_at: err.expected_updated_at,
2618
+ message:
2619
+ "Note was modified concurrently while arming the retry; re-fetch and try again.",
2620
+ },
2621
+ 409,
2622
+ );
2623
+ }
2624
+ throw err;
2625
+ }
2626
+
2627
+ const { getTranscriptionWorker } = await import("./transcription-registry.ts");
2628
+ const worker = getTranscriptionWorker();
2629
+ if (worker) {
2630
+ const fresh = await store.getAttachment(failed.id) ?? failed;
2631
+ void worker.kick(vault, fresh);
2632
+ }
2633
+
2634
+ return json(
2635
+ {
2636
+ status: "queued",
2637
+ attachment_id: failed.id,
2638
+ attachment_path: failed.path,
2639
+ transcript_note_id: note.id,
2640
+ worker: worker ? "kicked" : "sweep-only",
2641
+ },
2642
+ 202,
2643
+ );
2644
+ }
2645
+
2043
2646
  // ---------------------------------------------------------------------------
2044
2647
  // Storage (file upload/serve) — kept as-is, Daily needs it
2648
+ //
2649
+ // `assetsDir` moved to config.ts (next to the other path helpers) to break the
2650
+ // usage.ts↔routes.ts import cycle; it's re-exported from this module's top so
2651
+ // existing importers are unaffected.
2045
2652
  // ---------------------------------------------------------------------------
2046
2653
 
2047
- export function assetsDir(vault: string): string {
2048
- return process.env.ASSETS_DIR ?? join(vaultDir(vault), "assets");
2049
- }
2050
2654
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
2051
2655
 
2052
2656
  // Storage allowlist policy:
@@ -2112,10 +2716,41 @@ export async function handleStorage(
2112
2716
  const relativePath = `${date}/${filename}`;
2113
2717
  const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
2114
2718
 
2719
+ // Invalidate the usage dir-walk cache for this vault — the new attachment
2720
+ // changed the assets-directory footprint, so the next /.parachute/usage
2721
+ // read must re-walk rather than report a stale (smaller) number. Without
2722
+ // this hook the cache's 60s TTL would briefly under-report after an
2723
+ // upload. (usage.ts:invalidateUsageCache is a no-op-cheap map delete.)
2724
+ invalidateUsageCache(vault);
2725
+
2115
2726
  return json({ path: relativePath, size: buffer.length, mimeType }, 201);
2116
2727
  }
2117
2728
 
2118
- const fileMatch = path.match(/^\/([^/]+)\/(.+)$/);
2729
+ // Decode percent-encoding BEFORE matching. `path` arrives from
2730
+ // `url.pathname`, which (per WHATWG) keeps an encoded `%2F` slash literal —
2731
+ // so a caller requesting `/api/storage/<date>%2F<file>` would never satisfy
2732
+ // the literal-slash match below and would fall through to the 404. Decoding
2733
+ // first accepts both the literal-slash and `%2F`-encoded forms, and yields
2734
+ // the literal-slash path that the DB stores (`${date}/${filename}`) so the
2735
+ // tag-scope reverse-lookup matches. This intentionally diverges from the
2736
+ // single-note routes, which decode their *first* segment only and therefore
2737
+ // REQUIRE `%2F` for slashes-in-an-id — a trap-grade asymmetry we accept here
2738
+ // because storage paths are always multi-segment date/file pairs.
2739
+ //
2740
+ // Guard-safety (verified): the traversal guard below operates on the
2741
+ // post-`normalize(join())` filesystem path, so a decoded `..` is still
2742
+ // caught → 403. Decode is idempotent for today's unencoded callers
2743
+ // (filenames are `<Date.now()>-<uuid>.<ext>` — no stray `%`). A malformed
2744
+ // `%` (e.g. `2026%2`) throws → 404, consistent with the no-existence-oracle
2745
+ // stance (and an improvement over the prior catch-all 500).
2746
+ let decodedPath: string;
2747
+ try {
2748
+ decodedPath = decodeURIComponent(path);
2749
+ } catch {
2750
+ return json({ error: "Not found" }, 404);
2751
+ }
2752
+
2753
+ const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
2119
2754
  if (req.method === "GET" && fileMatch) {
2120
2755
  const reqPath = `${fileMatch[1]}/${fileMatch[2]}`;
2121
2756
  const filePath = normalize(join(assets, reqPath));
@@ -2178,9 +2813,12 @@ export async function handleStorage(
2178
2813
  // Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
2179
2814
  // ---------------------------------------------------------------------------
2180
2815
 
2181
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<void> {
2816
+ // Returns the IDs of notes whose metadata was actually default-filled, so
2817
+ // the caller can re-read ONLY the mutated notes (and skip the re-read when
2818
+ // nothing changed). Mirrors the core/src/mcp.ts contract.
2819
+ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
2182
2820
  const schemas = tagSchemaOps.getTagSchemaMap(db);
2183
- if (Object.keys(schemas).length === 0) return;
2821
+ if (Object.keys(schemas).length === 0) return [];
2184
2822
 
2185
2823
  const defaults: Record<string, unknown> = {};
2186
2824
  for (const tag of tags) {
@@ -2192,8 +2830,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2192
2830
  }
2193
2831
  }
2194
2832
  }
2195
- if (Object.keys(defaults).length === 0) return;
2833
+ if (Object.keys(defaults).length === 0) return [];
2196
2834
 
2835
+ const mutated: string[] = [];
2197
2836
  for (const noteId of noteIds) {
2198
2837
  const note = await store.getNote(noteId);
2199
2838
  if (!note) continue;
@@ -2207,7 +2846,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2207
2846
  metadata: { ...existing, ...missing },
2208
2847
  skipUpdatedAt: true,
2209
2848
  });
2849
+ mutated.push(noteId);
2210
2850
  }
2851
+ return mutated;
2211
2852
  }
2212
2853
 
2213
2854
  function defaultForField(field: { type: string; enum?: string[] }): unknown {