# Spec: Move search scope taxonomy behind the Rust boundary

Status: Design authority — Stage 1 implemented, Stage 2 outstanding. The scope→item-type mapping now lives in Rust (`SearchScope::item_types()` in `repository/types.rs`, DR-063 … DR-067). The result-side grouping table (`GROUP_ITEM_TYPES` in `src/lib/utils/searchScope.ts`) is still in the frontend, and `check:boundary` does not match its shape. Delivery status and the remaining work live in scoped-search-boundary-implementation.md; this spec remains the design authority. Scope: Rust + Frontend. Revises a decision in scoped-search.md. Requirements: UR-049, UR-050 (existing) → new DRs for the boundary move (allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one new DR for the grouped result shape — see requirements.md). UX spec: unchanged — ux-flows.md §6. This is a pure architecture/boundary change with no user-visible behaviour difference.

## Why this spec exists

scoped-search.md shipped scoped search as "frontend only, no Rust changes." That was the smallest wiring change, and it worked — but it left Jellyfin's item-type taxonomy encoded in the presentation layer, which violates the project's core boundary rule ("Svelte frontend — presentation only"; all business logic in Rust — see CLAUDE.md and architecture/02-svelte-frontend.md).

The offending knowledge lives in searchScope.ts:

    const SCOPE_ITEM_TYPES = {
      music:  ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
      movies: ["Movie"],
      tv:     ["Series", "Episode"],
    };
    const GROUP_ITEM_TYPES = {
      songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
      movies: ["Movie"], tvShows: ["Series", "Episode"],
    };

This is a domain definition — "what the category Music means in Jellyfin's vocabulary" — expressed twice, in the wrong layer. The concrete failure it creates: the day the backend starts returning a type the frontend never enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently drops it from both the query filter and the result buckets, and nothing in the Rust layer — the actual authority on Jellyfin's API — can correct it. Two sources of truth that will drift.

This must be fixed while the feature is uncommitted, before the leak ships baked into a released wire contract.

### What is not a leak (leave it alone)

Single concrete-type list pages are not business logic and stay as-is:

- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`, `tv.ts` → `["Series"]`
- `GenericMediaListPage.svelte` → `[config.itemType]`
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`, `PersonDetailView`

"This page shows albums" is a legitimate presentation choice expressed through a generic `getItems(parentId, { includeItemTypes })` API. Only the search scope taxonomy (a semantic category → many types, defined once and reused) crosses the line. Do not invent a backend enum for every list page — that is over-abstraction, not cleaner separation.

## The boundary rule after this change

> The frontend never names a Jellyfin item type in connection with search. It sends an opaque `scope`, and receives results already sorted into labelled groups. The frontend owns only group order (presentation) and rendering.

## Design

### Rust owns scope → item-types (query side)

Add an opaque enum that crosses IPC, and move the expansion table into Rust:

    #![allow(unused)]
    fn main() {
    // repository/types.rs
    #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
    #[serde(rename_all = "camelCase")]
    pub enum SearchScope { All, Music, Movies, Tv }

    impl SearchScope {
        /// The Jellyfin item types this scope requests, or None for `All`
        /// (which must send NO includeItemTypes — see below).
        pub fn item_types(self) -> Option<Vec<String>> {
            match self {
                SearchScope::All    => None,
                SearchScope::Music  => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
                                            .into_iter().map(String::from).collect()),
                SearchScope::Movies => Some(vec!["Movie".into()]),
                SearchScope::Tv     => Some(vec!["Series".into(), "Episode".into()]),
            }
        }
    }
    }

`SearchOptions` gains `scope` and the search command resolves it into the existing `include_item_types` filter inside Rust, before dispatching to the online/offline paths (which already honour `include_item_types` — do not touch their filtering, per scoped-search.md §Background 2).

    #![allow(unused)]
    fn main() {
    pub struct SearchOptions {
        pub limit: Option<usize>,
        pub search_term: Option<String>,
        pub scope: Option<SearchScope>,        // NEW
        // include_item_types stays for the single-type list-page callers,
        // but the SEARCH command derives it from `scope` when scope is set.
    }
    }

Precedence: if `scope` is set it wins; `include_item_types` remains for the non-search `getItems` callers. Document this so a future reader does not send both.

`All` sends no filter. Preserve the existing invariant: `All` must omit `includeItemTypes` entirely, not send the union of every enumerated type — types nobody listed (Person, folders) would otherwise be filtered out. This is why `item_types()` returns `Option`, and the command must skip the filter on `None`.

### Rust owns result bucketing (result side)

Results arrive pre-grouped. Rust classifies each returned `MediaItem` into a group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:

    #![allow(unused)]
    fn main() {
    #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
    #[serde(rename_all = "camelCase")]
    pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }

    #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }

    #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
    #[serde(rename_all = "camelCase")]
    pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
    }

Rust emits every non-empty group it can classify, in a stable canonical order. It does not apply the user's ordering or drop out-of-scope groups — those are presentation and stay frontend-side (see below). Items whose type maps to no group are omitted from grouped output (same as today's frontend filter).

### 🔴 The `search-event` wrinkle — both payloads must change

Search returns results twice: the command resolves with instant local-cache results, then the merged cache+server union arrives later via the `search-event` listener (see library.ts `search()` and architecture/03-data-flow.md). Both the command return value and the `search-event` payload must carry `GroupedSearchResult`. If only one is converted, the instant results group and the merged ones do not (or vice versa), and the UI flickers between shapes. This is the single largest part of the change and the easiest to half-do.

### What the frontend keeps (all pure presentation)

searchScope.ts retains:

- `SearchScope` type — now sourced from the generated bindings, mirroring the Rust enum (delete the hand-written union).
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free, unit-tested. Stays exactly as-is.
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`, `DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all presentation.

searchScope.ts loses:

- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
- `scopeItemTypes()`, `groupItemTypes()`.
- The `.type`-inspecting body of `composeSearchGroups()`.

`composeSearchGroups()` shrinks to a presentation composition over Rust's groups — no `.type` inspection anywhere:

    // Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
    // attach labels, omit empties. No Jellyfin type vocabulary.
    composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]

`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is "is Songs part of the Music scope," arguably taxonomy. But because Rust already filtered the query by scope, out-of-scope groups will simply be empty and drop out via the empty-omit rule — so the frontend does not strictly need `GROUP_SCOPE` for correctness once Rust filters. Recommendation: delete `GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat it as a display hint, not authority.

### Frontend call-site changes

- library.ts `search(query, scope)` sends `{ scope }` in `SearchOptions` instead of computing `includeItemTypes`. Everything else (requestId bump, stale guard, 10s timeout, empty-query clear, event merge) is preserved.
- SearchResults.svelte consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` + client-side `composeSearchGroups(results, …)`. The store now holds grouped results.
- search/+page.svelte is unchanged in behaviour; only the type it passes to `SearchResults` changes.

## Out of scope

- Any change to online/offline `include_item_types` filtering — it already works; only the source of the type list moves.
- Single concrete-type list pages (see "What is not a leak").
- Ranking within or across groups.
- The UX / chip behaviour / persistence mechanism — all unchanged from scoped-search.md.

## Acceptance criteria

- No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains in `searchScope.ts` or any search call path. Verify: `grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
- `SearchScope` and `SearchGroupId` in the frontend come from the generated `bindings.ts`, not hand-written unions.
- Search behaviour is identical to today for the user: same scoping, same groups, same order, same empty/out-of-scope omission, offline included.
- Both the command return and the `search-event` payload carry the grouped shape; no shape flicker between instant and merged results.
- `All` scope still sends no `includeItemTypes` (assert in a Rust test).
- Adding a hypothetical new type to a scope requires editing only Rust.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- `bun run check` and `bun run test` pass; `bindings.ts` regenerated and committed.

## Testing

Rust (`src-tauri`, `cargo test`):

- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
- Search command: `scope: Music` resolves to the four music types on the query; `scope: All` sends no `include_item_types`.
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s; unknown types are dropped; groups come out in canonical order.
- The `search-event` payload is the grouped shape (guard the wrinkle).

Frontend (vitest, `src/lib/**/*.test.ts`) — update existing tests:

- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the outgoing options — rewrite to assert `scope` is sent instead.
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and extend `resolveSearchScope`, order normalize/move/reorder, and the new compose-over-groups (order + empty-omit, no type inspection).
- `searchGroupOrder.test.ts` — unchanged.

## TRACES

Per CLAUDE.md, tag requirement-implementing code:

- `SearchScope` enum + `item_types()` + search command scope resolution: `UR-049 | DR-063` (revised — resolution now Rust-side).
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for the wire shape.
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).

## Notes for the implementer

- This spec revises scoped-search.md §Background 2 and §Design "Scope model / Threading scope through the store," which asserted no Rust change. Update that spec's status to note the boundary was moved, or add a banner pointing here — do not leave the two specs contradicting silently.
- The IPC camelCase rule applies to the new enums and structs (CLAUDE.md): `#[serde(rename_all = "camelCase")]` on structs; the tagged-enum tag convention if any enum becomes tagged. Add/extend a `tauriIntegration`-style test if a new command is introduced.
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust types; do not hand-edit it.
- Another Claude session may be active in these same files (per project memory). `git diff` before repairing anything unexpected; these search files are exactly the ones a parallel session touched.