Audit of the principles in CLAUDE.md and docs/architecture/ against the actual code. Principles with a working automated check (poison-tolerant locking, Android source sync, one-directional playback state, graceful backend init, reachability-from-traffic) all held up. The two that drifted are exactly the two whose checks were broken or too narrow: - traceability-gate-repair: CI divided by hardcoded denominators (UR/39, IR/24, DR/48, JA/3, total 114) while requirements.md had grown to 211, reporting 158% coverage — the 50% threshold was unreachable and the job could not fail. - req-coverage-script-removal: check-req-coverage.sh reports "1 requirement" and prints "all requirements have implementations". - scoped-search-boundary-implementation: the founding boundary incident was specced but never built; the leak is still live. - boundary-tripwire-hardening: check:boundary passes on that same leak — the pattern is anchored to the query site, so a named const evades it. - player-facade-enforcement: 52 direct commands.player* call sites outside the facade, and no automated check at all. Each spec follows SPEC-TEMPLATE.md with a filled-in Layer assignment table and is checked against SPEC-REVIEW-CHECKLIST.md.
255 lines
13 KiB
Markdown
255 lines
13 KiB
Markdown
# 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.
|