# Spec: Harden the frontend boundary tripwire

**Status:** Implemented
**Requirements:** DR-094
**UX spec:** n/a — developer tooling.
**Supersedes / revises:** revises the detection rule in
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
is unchanged.

## Summary

`bun run check:boundary` passes on a tree that contains the exact leak it was
built to catch. It matches a multi-type array only when written **inline at the
query site**, so assigning the same array to a named const evades it entirely —
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
category→item-type mapping through every green CI run. This spec broadens the
match to item-type array literals anywhere in `src/`, and resolves the handful
of legitimate hits that broadening surfaces.

## Motivation

The current pattern is anchored to the `includeItemTypes:` key:

```sh
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
```

The live leak is not written that way:

```ts
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
```

The taxonomy and the query are one indirection apart, and the grep only sees the
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
PROOF" — but the gap here is not a subtle judgment call it was designed to
defer to human review. It is the *crudest form* of the violation, one `const`
away from the shape it does match, in the very file the founding incident was
written about.

Broadening the pattern to any item-type array literal finds it, with a
manageable number of other hits (measured, not estimated):

| Site | Verdict |
|------|---------|
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |

Four non-test sites total. This is a tractable change, not a boil-the-ocean one.

## Layer assignment

Tooling only — no application logic, nothing crosses IPC. The two borderline
*application* sites do get a layer decision, below.

| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |

`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
Out of scope for why the migration itself is deferred rather than bundled.

## Design

### 1. Broaden the pattern

Replace the key-anchored pattern with one matching an array literal of two or
more known Jellyfin item types, wherever it appears:

```sh
# Two or more adjacent item-type string literals inside a bracket.
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
```

Properties worth stating, because each is a deliberate trade:

- **Not anchored to any key**, so a named const, a function return, a `Record`
  value, or an inline query all match equally.
- **Requires two adjacent type literals**, preserving the existing and correct
  carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
  still does not match.
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
  (`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.

Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
are — all three work.

### 2. Resolve the surfaced sites

- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
- `GenericMediaListPage.svelte` — **add to the allowlist** with the reason from
  the layer table (grid styling over a self-declared `itemType`).
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
  preferred fix** (backend `isContainer`). An allowlist entry that records a
  known-borderline decision is honest; silently broadening the pattern to miss
  it would not be.
- `searchScope.ts` — **not allowlisted.** It is the leak, and
  [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
  deletes it.

### 3. 🔴 Sequencing

**This spec must land after Stage 1 of
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
Hardening the tripwire first turns `master` red on a violation with no fix
available, and the only ways out are reverting the hardening or allowlisting the
leak — the second of which is exactly how a boundary rule dies.

### 4. Keep the allowlist honest

The script already warns that a growing allowlist means the boundary is eroding.
This change takes it from 1 entry to 3, which is close to that line. Add a hard
cap so drift is caught mechanically rather than by whoever notices:

```sh
MAX_ALLOWLIST=4
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
  echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
  echo "   Push taxonomy into Rust instead of appending here."
  exit 1
fi
```

The cap is deliberately just above the current count: the next exception forces
a conversation instead of a one-line append.

### 5. Restate the limits

The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
pattern still cannot see:

- a type set built at run time (`[...musicTypes, "Playlist"]`),
- types split across variables (`const A = "Audio"; [A, B]`),
- taxonomy expressed as a `switch` or chained `||` rather than an array.

The spec-review checklist remains the real gate. This raises the floor; it does
not close the class.

## Out of scope

- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
  `MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
  with a TODO here so it is recorded, not forgotten.
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
- Detecting the run-time-construction cases listed above.
- Extending the check to Rust or Kotlin (the rule is about `src/`).
- Changing the boundary *policy* in CLAUDE.md.

## Acceptance criteria

- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
      **fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
      that proves the fix — verify it explicitly before landing.
- [ ] On the post-fix tree, `bun run check:boundary` passes.
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
      file fails the check (regression test for the const-indirection evasion).
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
- [ ] Test files are still excluded (the 6 known test hits stay silent).
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
      entry fails the check via `MAX_ALLOWLIST`.
- [ ] The script header still states it is a tripwire, not a proof, and names the
      evasions it cannot see.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run test:all` passes.

## Testing

The script is bash and has no test harness. Verify by construction — each is a
temporary edit, run, revert:

1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
4. Add `itemType: "Movie"` → **must pass**.
5. Add a 5th allowlist entry → **must fail** on the cap.

Record the five results in the PR description. A grep-based gate that has never
been observed failing is indistinguishable from one that cannot fail — which is
the precise condition this whole spec exists to correct.

## TRACES

Allocate in `requirements.md`:

- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
  literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
  site), so a category→type mapping cannot evade the check via a named const;
  allowlist is capped to force taxonomy into Rust rather than accumulating
  exceptions." Category: Tooling. Status: Done on merge.

Shell scripts carry no `TRACES:` comment convention in this repo; reference
DR-094 in the script header comment instead.

## Notes for the implementer

- A parallel Claude session may be active in this repo — `git diff` before
  "repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
  ordering constraint that will break `master` if ignored.
- Test the regex against the current tree *before* committing:
  `grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
  four sites in the Motivation table.
- The `TYPES` list will need occasional extension as Jellyfin adds types.
  That is acceptable for a tripwire — an unlisted type produces a false
  negative, never a false positive, so the check degrades safely.
