# Spec: Land the scoped-search boundary fix (implementation)

Status: Stage 1 Implemented — Stage 2 (result-side grouping) outstanding Requirements: UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs) UX spec: n/a — zero user-visible change is the point (see Acceptance criteria). Supersedes / revises: implements scoped-search-boundary.md, which specified this fix but was never built. That spec remains the design authority; this one is the delivery plan and status correction.

## Summary

scoped-search-boundary.md diagnosed a domain-taxonomy leak, specified the fix in full detail, and became the justification for the project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the spec-review checklist. The fix was never implemented. The leak it describes is still live in `main`. This spec exists to close that gap and to correct the record — the codebase currently enforces a rule against a violation it still contains.

## Motivation

The mapping the rule forbids is present and in use:

    // src/lib/utils/searchScope.ts:29-32
    const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
      music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
      movies: ["Movie"],
      tv: ["Series", "Episode"],
    };

This is not dead code. library.ts:262 calls `scopeItemTypes(scope)` and puts the result straight into `options.includeItemTypes`. Meanwhile there is no `SearchScope` anywhere in `src-tauri/`:

    $ grep -rn "SearchScope" src-tauri/src --include='*.rs'
    (no output)

Three things make this the highest-value item found in the design-principles audit:

1. The rule's own founding incident is unremediated. CLAUDE.md cites this spec as "the incident this rule came from." A rule whose originating violation is still shipping is not credible.
2. The tripwire cannot see it. `bun run check:boundary` passes — it greps for a multi-type array literal at the query site, and this one is assigned to a named const and dereferenced elsewhere. Broadening the tripwire is specified separately by the tripwire hardening (DR-094, shipped); note that hardening it without landing this fix would turn `master` red.
3. The spec's own acceptance criterion fails today. "Adding a hypothetical new type to a scope requires editing only Rust" — adding a type to the Music scope right now requires editing `searchScope.ts`.

## Layer assignment

Unchanged from scoped-search-boundary.md §Design; restated so this spec is reviewable on its own.

Logic / responsibility | Layer | Why it belongs there
--- | --- | ---
Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | Rust | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed.
Result item → search group bucketing | Rust | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout.
`All` sends no filter at all (≠ union of enumerated types) | Rust | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies.
Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side.
`resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is.
Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum.
`GROUP_SCOPE` (which group belongs to which scope) | Delete | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to gone.

The `SearchScope` and `SearchGroupId` types come to the frontend from generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing what item types it expands to is.

## Design

Follow scoped-search-boundary.md §Design as written — `SearchScope` enum + `item_types()` in `repository/types.rs`, `SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`, scope-wins precedence, `All` → `None` → no filter. It is not restated here; duplicating it would create two drifting copies of the same design.

This spec adds only the delivery sequencing that the original left implicit.

### Staging: land it in two reviewable pieces

The original bundles the query side and the result side into one change. That is a large diff touching Rust types, `bindings.ts`, the store, and a component, with the `search-event` dual-payload hazard in the middle. Split it:

Stage 1 — query side (closes the leak). `SearchScope` enum, `SearchOptions.scope`, command resolves scope → `include_item_types` in Rust, `library.ts` sends `{ scope }`, delete `SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.

After Stage 1 the actual boundary violation is gone and the hardened tripwire (DR-094) can pass.

Stage 2 — result side. `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`, Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk, `GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.

Both stages are required for the original spec's acceptance criteria to pass; Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. Stage 1 is not a stopping point — it is a review boundary. Do not mark the parent spec Implemented until Stage 2 lands.

### Stage 1 — delivered (July 2026)

- `SearchScope` enum + `item_types()` in repository/types.rs; `All` → `None` → no filter.
- `SearchOptions.scope` with `resolve_scope()`; scope wins over `include_item_types`, which stays for the non-search `get_items` callers.
- `repository_search` resolves the scope once, before the cache/server split, so both phases filter identically.
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now re-exports `SearchScope` from the generated bindings instead of a hand-written union.
- library.ts sends `{ scope }`.
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the opaque scope is sent rather than an item-type list.

Verified: adding `"AudioBook"` to the Music scope changed zero files under `src/` — the criterion that failed before this work.

Stage 2 remains open: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side bucketing, single-type-per-group) are still in `searchScope.ts`, and both search payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.

### 🔴 The `search-event` dual payload (Stage 2)

The original flags this as "the single largest part of the change and the easiest to half-do." Restating because it is the one thing that silently breaks: search resolves twice — the command returns instant cache results, then the merged cache+server union arrives via `search-event`. Both payloads must carry `GroupedSearchResult`. Convert one and the UI flickers between shapes as server results land.

Write the failing test for the event payload first — the command return is the obvious half, the event is the half that gets forgotten.

### Note on `SearchOptions.scope` and specta

`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with `skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>` following that pattern so `All`/absent omits the key. Regenerate `bindings.ts` — `SearchOptions` there is currently `{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never hand-edit it.

## Out of scope

- Redesigning anything in scoped-search-boundary.md. If implementation shows the design wrong, revise that spec, don't fork it.
- Online/offline `include_item_types` filtering — already correct; only the source of the type list moves.
- Ranking within or across groups (DR-090 territory).
- Chip UX, scope persistence, group-order persistence — unchanged.
- The two lesser type-set sites in `DownloadedBrowse.svelte` and `GenericMediaListPage.svelte`, handled in the hardened tripwire (DR-094, see `scripts/check-frontend-boundary.sh`).
- Broadening the tripwire itself — same sibling spec.

## Acceptance criteria

Inherits every criterion from scoped-search-boundary.md §Acceptance criteria. Additionally:

- `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches — the enum exists in Rust (it does not today).
- `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts` returns nothing.
- `grep -rn "scopeItemTypes" src/` returns nothing — including the `library.ts` import and call site.
- `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not hand-edited.
- Behaviour is byte-identical for the user: same scoping, same groups, same order, same empty-group omission, offline included. This spec is a pure refactor — any visible change is a defect.
- `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by inspection).
- Adding a type to the Music scope requires editing only Rust — demonstrate by making the edit and confirming no `src/` file changes.
- `scoped-search-boundary.md` status flips to Implemented, and `scoped-search.md`'s "frontend only, no Rust changes" framing gets a banner pointing at the corrected design.
- `bun run check` and `bun run test` pass.
- `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- `bun run check:boundary` passes.
- Changed code carries `// TRACES:` comments (IDs below).

## Testing

Follow scoped-search-boundary.md §Testing. Emphases:

Rust (`cargo test`):

- `SearchScope::item_types()` per scope; `All` → `None`.
- Scope resolution happens before the online/offline split, so both paths get the same filter — a regression here is invisible until someone searches offline.
- `scope` set + `include_item_types` set → scope wins (the documented precedence; assert it rather than trusting the doc).
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped; canonical group order; the `search-event` payload is the grouped shape.

Frontend (`bun run test`):

- `resolveSearchScope()` tests in `searchScope.test.ts` must pass unchanged — they cover the part that is not moving, and are the regression net proving the refactor didn't disturb routing.
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type` inspection in the implementation.

Offline parity: run a scoped search with the server unreachable and confirm identical grouping. The offline repository path honours `include_item_types` independently, and this is the case most likely to be missed.

## TRACES

No new requirement IDs — this implements existing ones. Retag as the code moves:

    #![allow(unused)]
    fn main() {
    // src-tauri/src/repository/types.rs
    /// TRACES: UR-049 | DR-063
    pub enum SearchScope { … }
    }

    // src/lib/utils/searchScope.ts — keep the file header; it retains
    // resolveSearchScope + group-order presentation logic.
    // TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067

Update DR-063's text in `requirements.md` to state that scope expansion is owned by Rust, so the requirement stops describing the leaked design. New Rust tests take `@req-test: UT-089` onward (next free UT is UT-089).

## Notes for the implementer

- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes (CLAUDE.md §Gotchas).
- Read scoped-search-boundary.md first. This spec is deliberately thin on design; that one is the authority.
- Sequence with the sibling specs: Stage 1 here → then the hardened tripwire (DR-094). Hardening the tripwire first turns `master` red on a known-unfixed violation.
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look before starting — understanding why the fix stalled may surface a constraint the spec didn't record.
- The user-visible-change count for this spec is zero. If QA reports a difference in search results, that is a bug in the refactor, not an improvement.