@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
- import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
+ import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
+ import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
17
+ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
16
18
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
17
19
  import { transactionAsync } from "../core/src/txn.ts";
18
20
  import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
@@ -128,6 +130,56 @@ function json(data: unknown, status = 200): Response {
128
130
  return Response.json(data, { status });
129
131
  }
130
132
 
133
+ /**
134
+ * `json(...)` plus the `X-Parachute-Warnings` response header (vault#550) —
135
+ * `encodeURIComponent(JSON.stringify(warnings))`, set only when `warnings`
136
+ * is non-empty. Percent-encoded because HTTP header VALUES are
137
+ * Latin1/ASCII-only (the Fetch spec's ByteString requirement) while warning
138
+ * `message` text uses this codebase's normal house style (em dashes,
139
+ * curly-free but occasionally non-ASCII punctuation) — a raw
140
+ * `JSON.stringify` throws `TypeError: invalid header value` the first time
141
+ * a message isn't 7-bit-clean. Decode with `decodeURIComponent` then
142
+ * `JSON.parse`. Exists so bare-array responses (`GET /notes` without a
143
+ * cursor) can carry warnings WITHOUT changing their wire shape — a code
144
+ * consumer doing `for (const note of await res.json())` keeps working
145
+ * unmodified, while a consumer that cares can read the header. Envelope
146
+ * shapes (`{notes, next_cursor}`, `{nodes, edges}`) get the header too for
147
+ * uniform discoverability; callers that want warnings INLINE in an
148
+ * envelope should also spread them into the response body before calling
149
+ * this (see the cursor-envelope call sites in `handleNotesInner`).
150
+ */
151
+ function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200): Response {
152
+ const res = json(data, status);
153
+ if (warnings.length > 0) {
154
+ res.headers.set("X-Parachute-Warnings", encodeURIComponent(JSON.stringify(warnings)));
155
+ }
156
+ return res;
157
+ }
158
+
159
+ /**
160
+ * REST-only `removed_param` warning (vault#550) — the flat `date_field` /
161
+ * `date_from` / `date_to` query-string params were removed at 0.6.4
162
+ * (vault#288) and have been silently ignored ever since (routes.ts:633-ish
163
+ * comment). A request that passes any of them now gets a warning naming
164
+ * the ignored param and the bracket-style replacement, instead of quietly
165
+ * coming back unfiltered. One warning per param present (a caller passing
166
+ * all three gets three entries — precise, not lossy-summarized).
167
+ */
168
+ function collectRemovedParamWarnings(url: URL): QueryWarning[] {
169
+ const REMOVED_DATE_PARAMS = ["date_field", "date_from", "date_to"] as const;
170
+ const warnings: QueryWarning[] = [];
171
+ for (const param of REMOVED_DATE_PARAMS) {
172
+ if (url.searchParams.has(param)) {
173
+ warnings.push({
174
+ code: "removed_param",
175
+ message: `\`${param}\` was removed in 0.6.4 (vault#288) and is silently ignored — use bracket-style date filters instead: \`meta[created_at][gte]=…\` / \`meta[created_at][lt]=…\` (or \`meta[updated_at][...]\`).`,
176
+ param,
177
+ });
178
+ }
179
+ }
180
+ return warnings;
181
+ }
182
+
131
183
  function parseBool(val: string | null, defaultVal: boolean): boolean {
132
184
  if (val === null) return defaultVal;
133
185
  return val === "true" || val === "1";
@@ -221,6 +273,59 @@ function parseInt10(val: string | null): number | undefined {
221
273
  return isNaN(n) ? undefined : n;
222
274
  }
223
275
 
276
+ /**
277
+ * Parse + validate `?limit=` / `?offset=` for the structured-query path
278
+ * (vault#550). Before this, `parseInt10(...) ?? 50` silently swallowed a
279
+ * non-numeric value into the default (a caller's typo `?limit=fifty` just
280
+ * became `limit=50` with no signal), and any negative value that DID parse
281
+ * flowed straight through to SQLite, whose `LIMIT -1` means "no limit" —
282
+ * silently returning EVERYTHING instead of erroring. Returns `{value}`
283
+ * (undefined when the param is absent — the caller applies its own
284
+ * default) or `{error}` (400 `invalid_query`) when present-but-malformed.
285
+ * `got` echoes the ORIGINAL raw string so the error is legible even though
286
+ * the coerced value (e.g. `NaN`) wouldn't be.
287
+ */
288
+ function parseNonNegativeIntParam(
289
+ url: URL,
290
+ key: "limit" | "offset",
291
+ ): { value?: number; error?: Response } {
292
+ const raw = parseQuery(url, key);
293
+ if (raw === null || raw === "") return {};
294
+ const trimmed = raw.trim();
295
+ if (!/^-?\d+$/.test(trimmed)) {
296
+ return {
297
+ error: json(
298
+ {
299
+ error: `\`${key}\` must be an integer, got ${JSON.stringify(raw)}`,
300
+ error_type: "invalid_query",
301
+ code: "INVALID_QUERY",
302
+ field: key,
303
+ got: raw,
304
+ hint: `pass a non-negative integer (e.g. ?${key}=${key === "limit" ? "50" : "0"}), or omit for the default`,
305
+ },
306
+ 400,
307
+ ),
308
+ };
309
+ }
310
+ const n = parseInt(trimmed, 10);
311
+ if (n < 0) {
312
+ return {
313
+ error: json(
314
+ {
315
+ error: `\`${key}\` must be zero or greater, got ${n}${key === "limit" ? " — a negative limit used to silently mean \"unlimited\" (SQLite semantics leaking through)" : ""}`,
316
+ error_type: "invalid_query",
317
+ code: "INVALID_QUERY",
318
+ field: key,
319
+ got: raw,
320
+ hint: "pass a non-negative integer, or omit for the default",
321
+ },
322
+ 400,
323
+ ),
324
+ };
325
+ }
326
+ return { value: n };
327
+ }
328
+
224
329
  /**
225
330
  * Parse bracket-style metadata filters (vault#285 friction point 1.3).
226
331
  *
@@ -556,6 +661,39 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
556
661
  return { expand: expandParam as TagExpandMode };
557
662
  }
558
663
 
664
+ /**
665
+ * Parse + validate `?search_mode=` (vault#551) — how `search` text is
666
+ * turned into an FTS5 query. "literal" (default, omit or pass explicitly):
667
+ * escape + phrase-quote so punctuation is literal content, not FTS5
668
+ * syntax. "advanced": raw FTS5 passthrough (pre-#551 behavior) for
669
+ * boolean/phrase/prefix operators. Same validation shape as
670
+ * `parseExpandParam` — loud 400 on an unknown value rather than a silent
671
+ * fallback to the default.
672
+ *
673
+ * Returns `{ mode }` (undefined when absent/empty → search branch defaults
674
+ * to "literal") or `{ error }` (a 400 Response) on an unknown value.
675
+ */
676
+ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response } {
677
+ const raw = parseQuery(url, "search_mode");
678
+ if (raw === null || raw === "") return {};
679
+ if (!isValidSearchMode(raw)) {
680
+ return {
681
+ error: json(
682
+ {
683
+ error: `invalid \`search_mode\` value "${raw}" — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
684
+ code: "INVALID_QUERY",
685
+ error_type: "invalid_query",
686
+ field: "search_mode",
687
+ got: raw,
688
+ hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
689
+ },
690
+ 400,
691
+ ),
692
+ };
693
+ }
694
+ return { mode: raw };
695
+ }
696
+
559
697
  /**
560
698
  * Parse the shared notes-query parameters (tags / excludeTags / path /
561
699
  * pathPrefix / extension / metadata filters / date filters / sort / paging /
@@ -612,6 +750,12 @@ export function parseNotesQueryOpts(url: URL): {
612
750
  if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
613
751
  const expand = expandParsed.expand;
614
752
 
753
+ // limit/offset validation (vault#550) — see parseNonNegativeIntParam.
754
+ const limitParsed = parseNonNegativeIntParam(url, "limit");
755
+ if (limitParsed.error) return { hasSearch, hasNear, hasCursor, error: limitParsed.error };
756
+ const offsetParsed = parseNonNegativeIntParam(url, "offset");
757
+ if (offsetParsed.error) return { hasSearch, hasNear, hasCursor, error: offsetParsed.error };
758
+
615
759
  const queryOpts: QueryOpts = {
616
760
  tags,
617
761
  tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
@@ -639,8 +783,8 @@ export function parseNotesQueryOpts(url: URL): {
639
783
  ...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
640
784
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
641
785
  orderBy: parseQuery(url, "order_by") ?? undefined,
642
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
643
- offset: parseInt10(parseQuery(url, "offset")),
786
+ limit: limitParsed.value ?? 50,
787
+ offset: offsetParsed.value,
644
788
  cursor: cursorParam ?? undefined,
645
789
  };
646
790
 
@@ -839,8 +983,11 @@ async function handleNotesInner(
839
983
  // FTS owns its own ordering (relevance, not updated_at), so a cursor
840
984
  // would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
841
985
  // would otherwise route into the `if (search)` branch below and
842
- // silently drop the cursor. Reject here for surface parity.
843
- if (search && parseQuery(url, "cursor")) {
986
+ // silently drop the cursor. Reject here for surface parity. Presence
987
+ // check (`!== null`), not truthiness (vault#550) — a bootstrap
988
+ // `?cursor=` attempt against a search query is still cursor intent
989
+ // and must still be rejected, not silently treated as "no cursor."
990
+ if (search && parseQuery(url, "cursor") !== null) {
844
991
  return json(
845
992
  {
846
993
  error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
@@ -860,11 +1007,54 @@ async function handleNotesInner(
860
1007
  // value. The validated mode is threaded into the search tag-narrowing.
861
1008
  const tagExpand = parseExpandParam(url);
862
1009
  if (tagExpand.error) return tagExpand.error;
863
- const rawResults = await store.searchNotes(search, {
864
- tags: searchTags,
865
- limit,
866
- expand: tagExpand.expand,
867
- });
1010
+ // `search_mode` (vault#551) same loud-validation policy as
1011
+ // `expand` above; also bypasses `parseNotesQueryOpts` so it needs
1012
+ // its own check here.
1013
+ const searchModeParsed = parseSearchModeParam(url);
1014
+ if (searchModeParsed.error) return searchModeParsed.error;
1015
+ const mode: SearchMode = searchModeParsed.mode ?? "literal";
1016
+ // `sort` under search (vault#551 item 3): omit for FTS5 relevance
1017
+ // (default, unchanged); explicit asc/desc switches to created_at.
1018
+ const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
1019
+
1020
+ const searchWarnings: QueryWarning[] = [];
1021
+ let rawResults: Note[];
1022
+ // "Only whitespace/quotes" (vault#551 edge case) — short-circuit
1023
+ // BEFORE ever calling FTS5 rather than risk a syntax error (or a
1024
+ // meaningless always-empty query) on a degenerate escaped phrase.
1025
+ if (mode === "literal" && buildLiteralSearchQuery(search).isEmpty) {
1026
+ rawResults = [];
1027
+ searchWarnings.push(emptySearchWarning());
1028
+ } else {
1029
+ try {
1030
+ // A malformed `search_mode: "advanced"` query throws here
1031
+ // (structured `invalid_search_syntax`, vault#551) instead of
1032
+ // the pre-#551 silent `[]` — caught below and formatted the
1033
+ // same way the structured-query path formats a `QueryError`.
1034
+ rawResults = await store.searchNotes(search, {
1035
+ tags: searchTags,
1036
+ limit,
1037
+ expand: tagExpand.expand,
1038
+ mode,
1039
+ sort,
1040
+ });
1041
+ } catch (e: any) {
1042
+ if (e && e.name === "QueryError") {
1043
+ return json(
1044
+ {
1045
+ error: e.message,
1046
+ code: e.code ?? "INVALID_QUERY",
1047
+ ...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
1048
+ ...(e.field !== undefined ? { field: e.field } : {}),
1049
+ ...(e.got !== undefined ? { got: e.got } : {}),
1050
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1051
+ },
1052
+ 400,
1053
+ );
1054
+ }
1055
+ throw e;
1056
+ }
1057
+ }
868
1058
  // Tag-scope: drop any result the token isn't permitted to see. Filter
869
1059
  // happens after the store query so an empty post-filter list still
870
1060
  // returns 200 [] (consistent with "no matches"), not 403.
@@ -902,7 +1092,7 @@ async function handleNotesInner(
902
1092
  );
903
1093
  for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
904
1094
  }
905
- return json(output);
1095
+ return jsonWithWarnings(output, searchWarnings);
906
1096
  }
907
1097
 
908
1098
  // Structured query
@@ -934,11 +1124,22 @@ async function handleNotesInner(
934
1124
  if (parsed.error) return parsed.error;
935
1125
  const queryOpts = parsed.queryOpts!;
936
1126
  const cursorParam = parseQuery(url, "cursor");
1127
+ // Cursor mode is keyed on PRESENCE (`!== null`), not truthiness
1128
+ // (vault#550 bootstrap fix). `?cursor=` (present, empty) is the
1129
+ // bootstrap call — "I want to paginate, no watermark yet" — and must
1130
+ // still engage the `{notes, next_cursor}` envelope. Before this fix
1131
+ // `if (cursorParam)` treated an empty string exactly like an absent
1132
+ // param, so the FIRST call could never obtain a cursor even though
1133
+ // `core/src/mcp.ts` documented that flow — the cursor bootstrap gap
1134
+ // this wave closes. Omitting `cursor` entirely (`cursorParam ===
1135
+ // null`) is the only way to opt OUT — that keeps today's bare-array
1136
+ // compat shape.
1137
+ const cursorMode = cursorParam !== null;
937
1138
  // Opaque cursor for "since last checked" agent loops (vault#313) is
938
1139
  // mutually exclusive with the `near` graph-neighborhood scope (rebuilding
939
1140
  // the neighborhood per page isn't stable).
940
1141
  const nearNoteIdEarly = parseQuery(url, "near[note_id]");
941
- if (cursorParam && nearNoteIdEarly) {
1142
+ if (cursorMode && nearNoteIdEarly) {
942
1143
  return json(
943
1144
  {
944
1145
  error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
@@ -947,10 +1148,35 @@ async function handleNotesInner(
947
1148
  400,
948
1149
  );
949
1150
  }
1151
+ // Warnings channel (vault#550). `unknown_tag` stays structured-query
1152
+ // only (skipped entirely for a tag-scoped session:
1153
+ // `collectUnknownTagWarnings` resolves `did_you_mean` against the
1154
+ // FULL vault-wide tag catalog, which would leak an out-of-scope tag's
1155
+ // existence/name across the scope boundary — the same "no leak"
1156
+ // stance this codebase takes everywhere else
1157
+ // (docs/contracts/tag-scoped-tokens.md)). `removed_param` warnings
1158
+ // carry no tag information and fire regardless of scope. `search_mode`
1159
+ // without `search` (vault#551) also carries no tag information — this
1160
+ // IS the structured-query path precisely because `search` was absent
1161
+ // or empty, so a `search_mode` param here is always a no-op.
1162
+ const queryWarnings: QueryWarning[] = [
1163
+ ...collectRemovedParamWarnings(url),
1164
+ ...(parseQuery(url, "search_mode")
1165
+ ? [
1166
+ ignoredParamWarning(
1167
+ "search_mode",
1168
+ "no `search` was provided — search_mode only affects full-text search query parsing",
1169
+ ),
1170
+ ]
1171
+ : []),
1172
+ ...(tagScope.allowed === null
1173
+ ? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
1174
+ : []),
1175
+ ];
950
1176
  let results: Note[];
951
1177
  let nextCursor: string | null = null;
952
1178
  try {
953
- if (cursorParam) {
1179
+ if (cursorMode) {
954
1180
  const page = await store.queryNotesPaged(queryOpts);
955
1181
  results = page.notes;
956
1182
  nextCursor = page.next_cursor;
@@ -960,9 +1186,23 @@ async function handleNotesInner(
960
1186
  } catch (e: any) {
961
1187
  // QueryError (non-indexed order_by, unknown operator, ...) surfaces
962
1188
  // here. Duck-type on `name` + `code` — core is a separate module, so
963
- // `instanceof` is fragile across bundling boundaries.
1189
+ // `instanceof` is fragile across bundling boundaries. The newer
1190
+ // vault#550 call sites (limit/offset/date validation in
1191
+ // core/src/notes.ts) additionally set `error_type`/`field`/`got`/
1192
+ // `hint` — merged in when present; long-standing QueryError throws
1193
+ // that don't set them keep their existing `{error, code}` shape.
964
1194
  if (e && e.name === "QueryError") {
965
- return json({ error: e.message, code: e.code ?? "INVALID_QUERY" }, 400);
1195
+ return json(
1196
+ {
1197
+ error: e.message,
1198
+ code: e.code ?? "INVALID_QUERY",
1199
+ ...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
1200
+ ...(e.field !== undefined ? { field: e.field } : {}),
1201
+ ...(e.got !== undefined ? { got: e.got } : {}),
1202
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1203
+ },
1204
+ 400,
1205
+ );
966
1206
  }
967
1207
  // CursorError carries a structured code (cursor_invalid /
968
1208
  // cursor_query_mismatch) so the agent loop can distinguish a
@@ -1072,7 +1312,12 @@ async function handleNotesInner(
1072
1312
  }
1073
1313
  }
1074
1314
  }
1075
- return json({ nodes, edges });
1315
+ // Envelope shape → warnings inline (same policy as the cursor
1316
+ // envelope below), plus the header via jsonWithWarnings.
1317
+ return jsonWithWarnings(
1318
+ { nodes, edges, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1319
+ queryWarnings,
1320
+ );
1076
1321
  }
1077
1322
 
1078
1323
  if (includeLinks || includeAttachments) {
@@ -1099,12 +1344,24 @@ async function handleNotesInner(
1099
1344
  // Cursor mode wraps the list in {notes, next_cursor} so an agent
1100
1345
  // loop can chain calls without tracking a watermark client-side.
1101
1346
  // Legacy callers (no `cursor` param) still get the flat array.
1102
- if (cursorParam) return json({ notes: enrichedOut, next_cursor: nextCursor });
1103
- return json(enrichedOut);
1347
+ // Warnings (vault#550): inline on the envelope, header either way
1348
+ // (see jsonWithWarnings).
1349
+ if (cursorMode) {
1350
+ return jsonWithWarnings(
1351
+ { notes: enrichedOut, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1352
+ queryWarnings,
1353
+ );
1354
+ }
1355
+ return jsonWithWarnings(enrichedOut, queryWarnings);
1104
1356
  }
1105
1357
 
1106
- if (cursorParam) return json({ notes: output, next_cursor: nextCursor });
1107
- return json(output);
1358
+ if (cursorMode) {
1359
+ return jsonWithWarnings(
1360
+ { notes: output, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
1361
+ queryWarnings,
1362
+ );
1363
+ }
1364
+ return jsonWithWarnings(output, queryWarnings);
1108
1365
  }
1109
1366
 
1110
1367
  // POST /notes — create (single or batch)
@@ -1874,16 +2131,42 @@ export async function handleTags(
1874
2131
  if (singleTag) {
1875
2132
  // Tag-scope: a tag-scoped token can only see tags reachable from its
1876
2133
  // allowlist (root + descendants per the parent_names hierarchy).
1877
- // Anything else 404s — same "no leak" stance as note reads.
2134
+ // Anything else 404s — same "no leak" stance as note reads. This
2135
+ // early-return means the vault#550 unknown-tag 404 below never even
2136
+ // runs for a scope-excluded name — `did_you_mean` (which searches
2137
+ // vault-wide) can't fire and leak an out-of-scope tag's existence.
1878
2138
  if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
1879
- return json({ error: "Tag not found", tag: singleTag }, 404);
2139
+ return json({ error: "Tag not found", error_type: "tag_not_found", tag: singleTag }, 404);
1880
2140
  }
1881
2141
  const allTags = await store.listTags();
1882
2142
  const found = allTags.find((t) => t.name === singleTag);
1883
2143
  const record = await store.getTagRecord(singleTag);
2144
+ // vault#550 — a tag with no identity row AND no notes carrying it
2145
+ // isn't a legitimate (if empty) tag; it's a typo or a tag from a
2146
+ // different vault. Return a structured 404 instead of synthesizing
2147
+ // an all-null 200. `did_you_mean` candidates are restricted to the
2148
+ // caller's allowlist when scoped (defense in depth — this branch is
2149
+ // normally unreachable for a scoped session per the early-return
2150
+ // above, but stays scope-safe if that ever changes).
2151
+ if (!found && !record) {
2152
+ const candidates = tagScope.allowed
2153
+ ? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
2154
+ : allTags.map((t) => t.name);
2155
+ const suggestion = suggestSimilarTag(candidates, singleTag);
2156
+ return json(
2157
+ {
2158
+ error: "Tag not found",
2159
+ error_type: "tag_not_found",
2160
+ tag: singleTag,
2161
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
2162
+ },
2163
+ 404,
2164
+ );
2165
+ }
1884
2166
  return json({
1885
2167
  name: singleTag,
1886
2168
  count: found?.count ?? 0,
2169
+ expanded_count: found?.expanded_count ?? 0,
1887
2170
  description: record?.description ?? null,
1888
2171
  fields: record?.fields ?? null,
1889
2172
  relationships: record?.relationships ?? null,
@@ -2069,15 +2352,36 @@ export async function handleTags(
2069
2352
 
2070
2353
  // GET /tags/:name — single tag detail (full record)
2071
2354
  if (req.method === "GET") {
2355
+ // Same "no leak" early-return as the `?tag=` form above — a
2356
+ // scope-excluded name 404s before `did_you_mean` (vault-wide) can run.
2072
2357
  if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
2073
- return json({ error: "Tag not found", tag: tagName }, 404);
2358
+ return json({ error: "Tag not found", error_type: "tag_not_found", tag: tagName }, 404);
2074
2359
  }
2075
2360
  const allTags = await store.listTags();
2076
2361
  const found = allTags.find((t) => t.name === tagName);
2077
2362
  const record = await store.getTagRecord(tagName);
2363
+ // vault#550 — see the `?tag=` form above for the rationale (no
2364
+ // identity row + no memberships = not a real tag, not a legitimate
2365
+ // empty one).
2366
+ if (!found && !record) {
2367
+ const candidates = tagScope.allowed
2368
+ ? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
2369
+ : allTags.map((t) => t.name);
2370
+ const suggestion = suggestSimilarTag(candidates, tagName);
2371
+ return json(
2372
+ {
2373
+ error: "Tag not found",
2374
+ error_type: "tag_not_found",
2375
+ tag: tagName,
2376
+ ...(suggestion ? { did_you_mean: suggestion } : {}),
2377
+ },
2378
+ 404,
2379
+ );
2380
+ }
2078
2381
  return json({
2079
2382
  name: tagName,
2080
2383
  count: found?.count ?? 0,
2384
+ expanded_count: found?.expanded_count ?? 0,
2081
2385
  description: record?.description ?? null,
2082
2386
  fields: record?.fields ?? null,
2083
2387
  relationships: record?.relationships ?? null,
@@ -440,6 +440,20 @@ describe("WS live-query — bad query + cap", () => {
440
440
  expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
441
441
  });
442
442
 
443
+ // vault#551 — literal-by-default search + `search_mode` are a
444
+ // snapshot/REST-only concept (escaping happens in `core/src/notes.ts`
445
+ // `searchNotes`, which the live matcher never calls). `search` itself
446
+ // stays categorically unsupported for live subscriptions regardless of
447
+ // mode — confirms `search_mode` doesn't accidentally open a bypass.
448
+ it("rejects an unsupported query with search_mode present too — search_mode doesn't bypass the search rejection", async () => {
449
+ const { server } = makeServer();
450
+ const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi&search_mode=advanced`, {
451
+ headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
452
+ });
453
+ expect(res.status).toBe(400);
454
+ expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
455
+ });
456
+
443
457
  it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
444
458
  const { binding } = makeServer({ maxSubscriptions: 2 });
445
459
  const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({