# 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](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](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:

```ts
// 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](../../src/lib/stores/library.ts#L262)
calls `scopeItemTypes(scope)` and puts the result straight into
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
`src-tauri/`**:

```console
$ 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 in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
   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](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](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
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.

**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](../../src-tauri/src/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](../../src/lib/stores/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](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
  [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
- Broadening the tripwire itself — same sibling spec.

## Acceptance criteria

Inherits every criterion from [scoped-search-boundary.md](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](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:

```rust
// src-tauri/src/repository/types.rs
/// TRACES: UR-049 | DR-063
pub enum SearchScope { … }
```

```typescript
// 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](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
  [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. 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.
