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.
13 KiB
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:
- 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.
- The tripwire cannot see it.
bun run check:boundarypasses — 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; note that hardening it without landing this fix would turnmasterred. - 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 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)
SearchScopeenum +item_types()in repository/types.rs;All→None→ no filter.SearchOptions.scopewithresolve_scope(); scope wins overinclude_item_types, which stays for the non-searchget_itemscallers.repository_searchresolves the scope once, before the cache/server split, so both phases filter identically.SCOPE_ITEM_TYPESandscopeItemTypes()deleted;searchScope.tsnow re-exportsSearchScopefrom 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_typesfiltering — 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.svelteandGenericMediaListPage.svelte, handled in boundary-tripwire-hardening.md. - 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.tsreturns nothing.grep -rn "scopeItemTypes" src/returns nothing — including thelibrary.tsimport and call site.SearchOptionsinbindings.tsincludesscope; 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.
Allscope sends noincludeItemTypes(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.mdstatus flips to Implemented, andscoped-search.md's "frontend only, no Rust changes" framing gets a banner pointing at the corrected design.bun run checkandbun run testpass.cargo fmtclean,cargo clippyclean,bun run test:rustpasses.bun run check:boundarypasses.- 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.
scopeset +include_item_typesset → 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; thesearch-eventpayload is the grouped shape.
Frontend (bun run test):
resolveSearchScope()tests insearchScope.test.tsmust pass unchanged — they cover the part that is not moving, and are the regression net proving the refactor didn't disturb routing.library.tssends{ scope }and neverincludeItemTypesfor search.composeSearchGroups()over fixtureSearchGroup[]with no.typeinspection 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:
// 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 diffbefore "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
boundary-tripwire-hardening.md. Hardening
the tripwire first turns
masterred on a known-unfixed violation. git log --oneline -- docs/specs/scoped-search-boundary.mdis 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.