Skip to content

feat(search): scope a search to multiple vaults - #245

Merged
kwoo24-oss merged 2 commits into
mainfrom
feat/search-multi-vault-scope
Jun 24, 2026
Merged

feat(search): scope a search to multiple vaults#245
kwoo24-oss merged 2 commits into
mainfrom
feat/search-multi-vault-scope

Conversation

@kwoo24-oss

Copy link
Copy Markdown
Collaborator

What

Search could only target one vault or all accessible vaults. This adds a multi-vault scope — pick a subset and search just those.

Asked for via /ui-ux-pro-max; design chosen: Popover + checkbox list + removable chips (Option A).

Backend

GET /search and GET /grep now accept a repeatable vault query param (?vault=a&vault=b). The chosen names are intersected with the caller's accessible set — a vault you can't read simply drops out (no leak) — via v.name = ANY($) in:

  • the metadata pre-filter (search_service.search),
  • the vector-ACL path (_accessible_vault_ids, now WHERE name = ANY($) AND <acl>),
  • the grep SQL, which now always applies the access predicate alongside the name filter (closing the old "named vault skips ACL" gap).

vault still accepts a single string for MCP / legacy callers (normalized to a one-element list internally), so nothing else changes.

Frontend

The /search page replaces the single-vault scope dropdown with a VaultScopePicker — a Radix DropdownMenu checkbox list (search-filterable, the same pattern as SelectMenu but CheckboxItem + stay-open) plus removable chips for the current selection. Empty = "All vaults (N)" (the calm default). Deep-linkable via ?v=a,b,c. searchDocs/grepDocs take string[].

Applied UX rules: progressive-disclosure (popover), autocomplete/search-accessible (filter), color-not-only + visual-hierarchy (chips), deep-linking + state-preservation (URL), informative aria-label (purpose + current value).

Verified locally

  • Multi-vault API (grep, 3-vault fixture): ?vault=a&vault=b → only a,b; ?vault=a → only a; no param → all. ✓
  • Backend ruff clean · mypy clean · 22 search unit tests pass.
  • Frontend design:check · typecheck · lint · 347 tests (incl. 3 new scope tests: ?v=a,bsearchDocs(q,[a,b],25), "All vaults (N)", count label + removable chips).
  • Visual: picker trigger "All vaults (2)" / "1 vault + ⟨graph-demo ×⟩" chip on the dev server.

backend 0.9.4.

🤖 Generated with Claude Code

kwoo24-oss and others added 2 commits June 24, 2026 17:22
Search could only target one vault or all accessible vaults. This adds a
multi-vault scope: pick a subset and search just those.

Backend: GET /search and GET /grep accept a repeatable `vault` query param
(?vault=a&vault=b). The chosen names are intersected with the caller's
accessible set (a vault you can't read drops out — no leak) via `v.name = ANY()`
in the metadata pre-filter, the _accessible_vault_ids vector-ACL path, and the
grep SQL (which now always applies the access predicate alongside the name
filter). `vault` still accepts a single string for MCP/legacy callers
(normalized to a one-element list), so nothing else changes.

Frontend: the /search page replaces the single-vault scope dropdown with a
VaultScopePicker — a Radix checkbox dropdown (search-filterable, same pattern as
SelectMenu) plus removable chips for the selection; empty = "All vaults (N)".
Deep-linkable via ?v=a,b,c. searchDocs/grepDocs take string[].

Verified locally: grep ?vault=a&vault=b returns only a,b (3-vault fixture);
single ?vault=a still works; backend ruff/mypy clean + 22 search unit tests;
frontend design/typecheck/lint + 347 tests (incl. 3 new scope tests); visual
check of the picker (trigger + chip). backend 0.9.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idates + tests

PR review (code-reviewer + silent-failure-hunter both flagged) found a CRITICAL:
the table and file candidate queries in `search()` still bound the RAW `vault`
param (now a `list[str]` from the REST route) to a scalar `v.name = $1`, so a
multi-vault search that takes the source-id path (any collection/type/tags
filter, or backfill-not-ready) raised on `text = text[]`. Only the documents
branch had been switched to `v.name = ANY()`.

- Fix: tables/files branches now use `vaults` + `v.name = ANY($1)` (mirrors the
  documents branch). Verified: `?q=…&type=note&vault=a&vault=b` → 200 (was 500).
- Extract `_normalize_vault_scope()` so search()/grep() share one normalization
  (kills the duplicated ternary + the `vault` vs `vaults` footgun).
- Comment fixes: `_accessible_vault_ids` docstring + call-site said a named-vault
  anon/admin scope returns None ("unscoped") — it returns ids (scoped); and
  search.tsx no longer calls `?v=` "legacy" (it's the active scope channel).
- Don't swallow a `listVaults()` failure silently (console.error so a broken
  /my/vaults is diagnosable).

Tests (the gap that let the CRITICAL through):
- backend `test_search_multi_vault_unit.py`: `_normalize_vault_scope` (str/list/
  empty/blank → canonical) + `_accessible_vault_ids` ACL INTERSECTION (a vault
  you can't read drops out — no leak; all-unreadable → []; admin named → ids).
- frontend interaction tests: check a vault → scope adds + re-search; remove a
  chip → drops; Clear → all vaults / searchDocs(undefined). (Radix dropdown
  driven via userEvent, mirroring role-select.test.tsx.)

Gates: backend ruff/mypy clean + multi-vault unit tests; frontend design/
typecheck/lint + 350 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kwoo24-oss
kwoo24-oss merged commit 892dc89 into main Jun 24, 2026
4 checks passed
@kwoo24-oss
kwoo24-oss deleted the feat/search-multi-vault-scope branch June 24, 2026 08:53
kwoo24-oss added a commit that referenced this pull request Jun 24, 2026
…p) (#246)

No behavior change — three cosmetic cleanups surfaced by the /simplify
review of PR #245's diff (4 cleanup angles: reuse, simplification,
efficiency, altitude):

- api.ts: drop the gratuitous IIFE in `grepDocs`; build the params with
  a plain block body, identical in shape to its sibling `searchDocs`.
- search.tsx: drop the `vs.length ? vs : undefined` ternary at both the
  searchDocs/grepDocs call sites. `vaultScopeParams` already collapses
  `[]` to "emit zero vault params" (== undefined on the wire), so the
  "empty = all vaults" transport rule now lives in exactly one place
  (api.ts) instead of being restated at every call site.
- search_service.py: collapse the duplicated 2-line shim-call comment at
  both `search()`/`grep()` sites to one line — `_normalize_vault_scope`'s
  docstring already documents the str|list|None → canonical contract.

Tests updated to assert the now-array-native no-scope call (`[]` instead
of `undefined`); behavior is identical (both produce an unscoped query).

Deliberately NOT done (agents agreed these are right as-is / out of
scope for a cleanup pass): generalizing VaultScopePicker into a ui/
multi-select primitive (N=1 caller), extracting the SelectMenu menu-shell
fork (defer until a 3rd dropdown), factoring the 3-place `v.name = ANY()`
filter (independent param lists), memoizing the picker's per-render Set
(negligible), and the VaultChip-in-chips visual change (behavior, not
cleanup).

Verified: frontend design:check + typecheck + lint (0 errors) + the two
search suites (10/10); backend ruff + test_search_multi_vault_unit (5/5).

Co-authored-by: kwoo24-oss <279600312+kwoo24-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant