Ctrl K
ComunidadGuías…

ARCHITECTURE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Curatarr — Architecture & Knowledge Base

> Living technical reference for anyone modifying the code.
> [README.md](README.md) is for *running* Curatarr and
> [docs/USAGE.md](docs/USAGE.md) for *operating* it; this file is for
> *understanding and changing* it. It captures the data flow, the
> non-obvious design decisions, and the tribal knowledge that isn't visible
> from any single file — the kind of thing you'd otherwise only learn by
> re-debugging a problem someone already solved.
>
> When you change a subsystem, update the matching section here.
> `CHANGELOG.md` records *what* changed per release; this file records the
> *current* mental model.

---

## Project layout

```
curatarr/
├── src/
│   ├── main.py                FastAPI app + lifespan
│   ├── config.py              Settings (env + defaults)
│   ├── middleware.py          Pure-ASGI stack: headers, Host + CF Access gates, token refresh
│   ├── log_setup.py           Logging bootstrap (file always, console if tty)
│   ├── paths.py               ROOT/DATA_DIR anchoring, CWD-independent
│   ├── tray_app.py            Windows tray launcher (production entry point)
│   ├── pc_agent.py            The node's reporter, started by its tray (§0 2026-10)
│   ├── self_update.py         Fast-forward-only update of a desktop checkout
│   ├── routers/               HTTP surface — one file per API area (§19)
│   ├── services/              Business logic — the §5-§13 subsystems plus
│   │                          the satellite modules indexed in §19
│   ├── database/              SQLAlchemy models + WAL connection
│   ├── schemas/               Pydantic request/response shapes
│   ├── vector_store/          ChromaDB wrapper
│   ├── cache/                 Versioned metadata cache (SQLite)
│   ├── embeddings/            Embedding generation
├── frontend/index.html        Single-page UI markup (vanilla, no build step)
├── frontend/css/app.css       the stylesheet (DESIGN LANGUAGE header first)
├── frontend/js/               app.js (entry: imports, init(), the window export block),
│                              state.js (shared mutable state) and one module per view
├── frontend/vendor/           marked.min.js + purify.min.js, bundled locally
├── scripts/                   Standalone runners, icon renderer, screenshot capture
├── tests/                     Plain-script battery — python tests/run_all.py
├── tests/benchmarks/          Model/prompt benchmarking harness (§19)
├── docs/README.md             Index: guides, references, design records, dated audits
├── docs/USAGE.md              Operator guide (incl. the container)
├── docs/BENCHMARKS.md         Model measurements behind the §15/§16 choices
├── docs/screenshots/          README images + how to capture them
├── Dockerfile                 The container image (ghcr.io/randomname653/curatarr)
├── deploy/docker-compose.yml  Compose / TrueNAS custom app
├── data/                      Runtime state (gitignored)
│   ├── curatarr.db            SQLite (WAL)
│   ├── cache/enrichment.db    Versioned API cache
│   └── chromadb/              Vector store
├── build_models.py            Bake the Ollama model tags
├── update_db.py               Idempotent schema migration
└── start.bat / start_tray.bat Windows launchers (console / tray)
```

`scripts/dev/` exists locally but is gitignored — one-shot debug and
repair tools written along the way, not part of the supported surface.

---

## 0. Deltas — subsystems newer than the sections below

The 2026-08 run and the ones after it added subsystems the numbered sections
don't cover yet. Until those sections are rewritten, this is the map, one
table per run. The 2026-10 security surface lives in §17, the database
concurrency rules in §4.

### 2026-08 (and its September follow-ups)

| Subsystem | Files | One-liner |
|---|---|---|
| Data Custodian | `src/services/data_custodian.py` | Debt-based maintenance: 31 tasks (`_registry`) with cadences + persisted last-run stamps; a 30-min tick runs whatever is overdue in two queues side by side (2026-09-27): tasks that drive a model one at a time in the model queue, everything else one at a time in the background queue, so a deletion scan holding the GPU for hours no longer stops the syncs and walkers. `Task.after` orders the few cross-queue dependencies: a model task waits (bounded) for the Plex sync it needs, a background task that needs model work defers instead of waiting. The Plex sync's model half (taste vectors with the curator summary, the recs cache) runs as `plex_followup` in the model queue. **Keep working** (2026-10-06): a sprint until a set time (`custodian_sprint_until` in app_state, `POST /api/enrichment/custodian/sprint?hours=`, or `python -m src.services.data_custodian --sprint HOURS`) restarts the idle model queue every two minutes with the deep budgets (the background walkers keep the 30-minute rhythm, they pace themselves against outside APIs) and holds a Windows power request (`SetThreadExecutionState`, its own thread) so the PC does not idle-sleep; it ends at that time, on Stop, or once no model work is due and no enrichment runs. Beside the queue the sprint keeps the enrichment pipeline going in 2,000-item chunks of every category (beside the queue, music's one MusicBrainz request a second holds no other GPU work up) while the cycle is due or the last run left items behind (`enrichment_left`); the queue's own cycle steps aside for the whole sprint, and a chunk that leaves nothing behind stamps it. Replaces most cron-shaped scheduler jobs. Every runner either creates its own Activity card or gets the tick's wrapper card (tested invariant). |
| Curated search v3 | `src/services/semantic_search.py` | LLM parses the query once (anchor/constraints); scoring is deterministic over raw enrichment tags (lexical-first, concept/tone families, negation, guards) with per-constraint evidence notes + coverage honesty. |
| Multi-vector facets | `src/services/facet_index.py`, collection `media_phrases_v1` + `facet_map.sqlite3` beside the store | Each title's theme phrases are its facets: one vector point per distinct phrase, and a table of which titles carry it (separate collection — mixing into `media_knowledge_v2` breaks n_results math, the anchor resolver, and taste calibration). Gives contrast queries resolution. The old per-title collection `media_facets_v1` is moved over once by the `facet_phrases` custodian job. |
| 4-pillar judge | `src/services/pillars.py` | Deletion verdicts (HARD_KEEP/KEEP_WITH_FLAG/CUT/STAGNANT/EVALUATE) from assembled evidence facts; KEEPs persist to ProtectedMedia; thin evidence skips the judge. `del_score` only pre-ranks. |
| Two-bake split | `src/services/llm_priority.py`, `PITCHER_MODEL` | Deletion runs use a dedicated pitcher bake (benchmarked); chat stays on the curator bake. Residency-guarded eviction; per-run `/api/tags` probe with visible fallback. |
| Owner match pins | `MediaMatchOverride`, hook at top of `fetch_and_prepare_raw`, "Fix match" UI | Durable entity resolution per (service, arr_id); overrides every automatic id source; apply purges the item's caches + flips status so the pipeline rebuilds on the pin. |
| Corpus hygiene | `src/services/corpus_repair.py`, audit in `src/routers/enrichment.py` | Audit walks chroma docs too: zombies requeue (arr-live) or rebuild deterministically from prefetch (arr-gone); corrupt-id clusters requeue; per-service implausible-mass-staleness guard (`src/services/stale_guard.py`). |
| Raw-cache refresher | `src/services/raw_refresh.py` | LLM-free background re-pull of API source data (discovery cards, expired prefetch incl. gone media) — the read-through cache no longer silently ages out. |
| Playlist reconcile | `src/services/plex_playlists.py` | In-place delta edits (identity/pins/art survive) + stale-ratingKey self-heal with write-back; music stays delete+recreate (album→track expansion). |
| Embedding profile SSOT | `src/services/embed_service.py` | The app_state embedding profile is the single truth (v2-moe stack); `effective_embedding_model()` feeds tray/setup/game-watcher checks; v1 collection deleted 2026-08-18. |
| Significance tri-state | `fetch_significance` / `topup_significance` | str = significance, `""` = definitive none (stamp), None = transient (no stamp — walker retries). |
| Tech-profile liveness | `plex_sync._prune_stale_tech_profiles`, `plex_sync._LAST_FETCH_COMPLETE`, `size_norms._copy_fingerprint`, `chat._check_protection_intent_bg` | A MediaTechProfile row lives only as long as Plex has its rating key: the tech sync prunes the rest, but only for categories whose sections were fetched completely and non-empty (an outage never reads as an emptied library). Identical files under different keys count as one copy in the DUPLICATE note and the redundancy audit. The intent hook's real outcome (protected; watchlist added or refused; downscale flagged or not) is stored as a ProactiveMessage for the bell, because the reply was streamed before the hook ran; the constitution and DISCUSSION_UI_BLOCK say the curator executes nothing itself. 2026-09-13, the Armor Shop case. |
| TMDB id namespaces | `size_norms._tmdb_namespace`, `routers/recommendations._namespace_for` | A TMDB id is NOT an identity on its own: films and series are numbered in separate sequences, so movie 90 and series 90 are unrelated works. Anything keyed on a bare `tmdb_id` pairs them. Group and look up by `(namespace, id)`; refuse rather than guess when the media type is unknown. One definition, delegated to — a second copy is how this came back once already. |
| Rows are not viewings | `series_progress` (`replays`, `abandoned_starts`, `count_real_views`), `plex_sync` RESUME_WINDOW_DAYS | One viewing can write two `watch_history` rows: Plex reports the partial view and the finished view through different queries. Counting raw rows also turns repeated ABANDONED starts into "replays", which inverts the signal. Count completed views, collapse those inside one viewing window, and let the finished view promote the unfinished row it belongs to. |
| FORM guards | `pillars.build_evidence` (`_is_spoken_word`, `_is_factual`) | Some works lose by default when measured with the wrong yardstick — cabaret judged on sonic fit, a documentary judged on narrative subversion. The evidence carries an explicit FORM line telling the judge which criteria apply. Add a guard per form, narrowly; a wrong yardstick is worse than none. |
| Batch language | `llm_utils.detect_user_language` | Surfaces with no live conversation (deletion pitches, proactive nudges) get English. They used to classify unrelated chat history, so the same title could be pitched in one language and re-evaluated in another. A per-user locale setting is the proper home for the general case. |
| Conversation starters | `src/services/chat_starters.py`, `/api/chat/starters`, custodian task `chat_starters` | Pooled curator OPENERS replacing the three hardcoded landing prompts: one LLM batch → a code-enforced diversity gate (distinct forms, distinct openings, mandatory fact anchor) → 48 h-TTL pool with impression decay. Clicking one makes the CURATOR say it (`starter:{id}` thread — same server-owned-context pattern as proposals). Day-named openers expire at local midnight and a pick-time guard retires day-mismatches; anchor_title/media_type pin verified data when the opener is about one work. The `active_series` fact spells out the viewing rhythm from real sittings (`viewing_sessions`) and the prompt allows "binge" only when the fact says so. |
| Status-poll memos | `src/services/ttl_memo.py` | `@ttl_response(seconds, key=…)` — in-process TTL cache + single-flight lock on the polled status endpoints (sync/enrichment/backfill/discover). They recomputed full-table aggregates every 10 s poll; now one compute per window serves all pollers. Exceptions are never cached. |
| Request telemetry + rolling session | `src/middleware.py`, `src/routers/auth.py` | `SlowRequestLogMiddleware` logs >300 ms-to-first-byte requests (the tool that found the poll hotspots). JWTs last 7 d; after 24 h of age `TokenRefreshMiddleware` re-issues via the `x-curatarr-refreshed-token` header so an active user never hits the mid-conversation 401 cliff. |
| Watched-title discussion | chat `watched_title` branch, `src/services/episode_context.py`, `src/services/watch_status.py` | Clicking the last-played strip opens a per-work thread (`watched:tmdb:{id}`) anchored on the user's OWN WatchHistoryEntry row. Episode position is stated as fact with an EPISODE-HONESTY rule — there is no per-episode metadata, and the curator must say so instead of inventing plot. |
| Chat output sanitization | `frontend/vendor/purify.min.js` | All LLM markdown renders via `DOMPurify.sanitize(marked.parse(…))` — marked passes raw HTML through by design, and curator output can echo text fetched from external metadata sources. Single sink, wrapped once. |
| Login membership + owner-first | `auth._assert_plex_membership`, `PLEX_LOGIN_REQUIRE_MEMBERSHIP` | Any plex.tv account used to become a user (the first one admin). New accounts must now appear in the server's `/accounts`; the first must be the token owner (`plex.tv/api/v2/user` with the server token). Fails closed for new accounts only. |
| Session revocation | `User.token_version`, JWT `ver` claim, `TokenRefreshMiddleware._load_refresh_subject` | `get_current_user` enforces `is_active` and a version match; logout/deactivation bump the version, and since 2026-09-25 so does `DELETE /api/users/{id}`, so re-enabling a deleted account does not revive its old tokens; the setup gate (`require_admin_or_first_run`) goes through the same `_user_from_bearer` instead of decoding the JWT itself. The rolling refresh re-issues from the DB row, never from stale claims. Pre-2026-09 tokens read as version 0 and stay valid. |
| First-run setup code | `auth.SETUP_CODE`, `_require_setup_code`, printed by `main.lifespan` | Pre-admin setup mutations require a console-printed one-time code unless the caller is the machine itself (`_is_local_browser`: loopback peer, loopback Host, no proxy headers, loopback or absent Origin; 2026-10-10); the frontend `api()` prompts for it on a 401 from `/api/setup/*` and replays. Wrong codes are budgeted per address and globally (10 / 100 per 15 minutes, then 429; localhost exempt) (2026-09-25). |
| .env write discipline | `setup_wizard._plain`, `write_env` | Unwraps `SecretStr` and refuses `**********` (library_configure once passed the raw objects — a forgeable JWT secret waiting to happen); carries the live JWT secret and tuned SYNC/BINGE values forward; atomic temp+replace; Windows ACL narrowed via icacls. |
| Per-user anchor cache | `chat._tkey` and friends | `_thread_active_title` was keyed by thread id alone while every user's free chat is thread `general` — a housemate could read and clear another's current topic via `/correct-anchor`. Keys are now `user_id:thread_id`. |
| Setup coverage | `setup_wizard.current_env_config/merge_env_config/mask_secrets`, `GET /api/setup/integrations`, `POST /api/setup/reconfigure`, Settings → Integrations | One source for the .env shape with secrets unwrapped; the wizard and the post-setup panel both overlay on it. The two-bake pitcher is a first-run choice (built by `complete_setup`, recommended by `model_catalog.PITCHER_MODELS` with a VRAM verdict); ListenBrainz and OpenSubtitles are managed keys; every connection is changeable after setup. Secrets never travel back to the browser (`{set: bool}`); `/test` falls back to stored secrets so the panel can test without re-typing. |
| Public surface | `SecurityHeadersMiddleware`, `main.FastAPI(openapi_url=…)`, `image_proxy._enforce_cache_budget` | CSP + Permissions-Policy; the OpenAPI spec is gated with the docs (it was public at `/openapi.json`); the unauthenticated poster cache has a size budget with oldest-first eviction. |
| Enrichment healing | `src/services/enrichment_state.py`, `kb_overview.classified_items`, `EnrichmentStatus.attempt_count/next_retry_at/match_basis/match_confidence`, `EnrichmentFinding`, `MediaMatchOverride.rejected_ids`, `/api/enrichment/items · unmatched · findings`, `_audit_enrichments` + `_triage_audit_hit` | ONE classifier for the KB tile, `/library/breakdown` and the producer (the old KB view read a column nothing wrote and counted a live not-found sentinel as enriched). Not-found rows carry an attempt counter with a backoff (2 free tries, then 3/6/12/24 d, capped at 30 d — never given up on; a category abort rolls its attempts back). Fetchers return a falsy `TRANSIENT` marker; a drained run with an unavailable source raises `TransientFetchError` and writes nothing. Sentinels persist their evidence (per-source outcomes, arr year, the rejected same-named hit) → `open_reason()` sentences. Title-search resolutions store a match basis + confidence (≥0.8 accept, 0.5–0.8 accept-and-flag, below with a disagreeing year refuse). Every KB count opens its list; Needs attention unions the row-derived reasons with the audit's persisted findings (SoulSync `repair_findings` contract: pending refreshed, dismissed silent, resolved re-raised after 7 d; each finding gets exactly ONE automatic requeue, then escalates to a human). Owner actions: pin (arr lookup + TMDB/AniList + free ids, category derived server-side), negative pin (all four title-search resolvers skip it; the purge covers title-keyed cache rows too), retry, ignore, dismiss. |
| Member budgets + untrusted text | `src/services/rate_limit.py` (`enforce`, `InFlight`), `llm_utils.fence_untrusted / scrub_untrusted / UNTRUSTED_RULE`, `format_verified_block` markers, `subtitle_signals._bounded_text`, `image_proxy._read_bounded`, `spotify_import.save_upload` caps, `plex_sync.sync_plex_history` lock | Second security angle (2026-09-06). Every endpoint that turns one request into LLM or external-API work carries a per-user sliding-window budget and, where the work is long, a one-at-a-time guard (chat reply, recommendation generation — including the GET lane that bypassed `/refresh-cache`). The global curator gate serialises the GPU; it never stopped one member from owning the queue. Third-party prose (overviews, reviews, wiki extracts, bios, the verified block) is fenced `<<<UNTRUSTED_SOURCE:…>>>` and scrubbed of markup / special tokens / role markers; every system prompt that receives it carries `UNTRUSTED_RULE`; `clean_llm_text` strips tag-like markup before persistence. The significance template is hashed into its cache stamp, so that path is scrubbed, not fenced. External bodies are capped while streaming (OpenSubtitles 4 MB + metrics off-loop, image proxy 5 MB, zip members 50 MB / 400 MB). One Plex sync at a time (`plex_sync_running`). |
| UI grammar | `frontend/css/app.css` (DESIGN LANGUAGE header) + `frontend/js/app.js` (helpers after `api()`: `toast`, `openModal`/`closeModal`, `confirmDialog`, `menuHtml`, `pagerHtml`, `btnBusy`/`btnDone`, `setBadge`, `emptyHtml`, `setStatus`, `trackDirty`), `tests/test_frontend_hygiene.py` | One anatomy at every depth (2026-09-06, §20): page = header → `.toolbar` → content → `.select-bar`; sub-panels are `.section`; rows are `.panel-item` with ≤3 visible actions + a More menu; outcomes are toasts, decisions are `confirmDialog`, dialogs share one root. The four old mechanisms (native alert/confirm/prompt, `style.cssText` overlays, button-text-only feedback, a `showToast` that never existed) are gone; the hygiene suite pins their counts and the inline-style budget so they only fall. |
| Lyrics from Plex | `src/services/lyrics.py` (collector `run_lyrics_sync`, profiler `run_lyrics_profiles`, `album_lyrics_line`), `data/cache/lyrics.db`, custodian tasks `lyrics_sync` / `lyrics_profile`, `_lyrics_prompt_block` + `_lyrics_line` in media_enricher | The curator judges music with the artist's own words (2026-09-14): SoulSync drops .lrc/.txt sidecars, Plex serves them as lyric streams, the collector keeps the plain lines in its own SQLite and re-checks every run (the trickle lands, a refused stream backs off a week), the profiler condenses a sample per artist into a lyrics profile (subjects, themes, languages, explicit, motifs, tone, up to three verbatim lines) that is attached to the raw entry, read by the summariser (`LYRICS PROFILE` block; without one, no claims about the words) and shown in the verified block; the constitution caps quotes at two short lines. Raw lyrics never reach a prompt or the UI. |
| Lidarr optional | `src/services/music_source.py` (`music_service`), `plex_artists`/`plex_artist_lookup`/`plex_artist_albums` in lyrics.py, `_plex_music_candidates` + `_plex_delete_artist` in recommendations.py, `_plex_music_library`/`_reenrich_plex_artist`/wishes in library.py, `plex_discography_summary`, `_plex_album` in album_dossier.py, `MusicWish` | Three set-ups work (2026-09-15): Lidarr alone, Lidarr + SoulSync, none. Without Lidarr the daily Plex walk is the music index (artists with mbid, albums, tracks, footprint), deletion candidates come from it in the Lidarr shape, deletions go through Plex ('Allow media deletion') with a re-read that treats a 200 as failure and an immediate index drop, the Music page shows the index with a Wanted tab, adds become wishes (MusicWish) the owner fulfils in SoulSync, the discography line and the album dossier come from the index, the Knowledge Base counts and the Discogs artist universe read it too. |
| Late arrivals find their history | `src/services/music_rematch.py` (`arrivals`, `rematch_arrivals`), phase 1b of `run_music_pipeline`, app_state `music_rematch_added_at:<uid>` | Music that lands later still claims the plays waiting for it (2026-09-21). The forward match gives each play one attempt ever — a cursor bought that after the old full re-scan froze the app for about a minute — so a library that grows by trickle could never improve its match rate. Measured, the premise was wrong: the minute came from materialising 203k ORM rows; three columns and the same normalisation take 0.99 s. So the match runs the other way, asking each newly arrived track whether the history was waiting for it, off the local Plex track index the lyrics collector refreshes daily — no extra Plex call, and a quiet run is one indexed query. First pass on the owner's data: 686 tracks claimed 5,842 historic plays in 1.1 s. |
| Music-pipeline miss memory | `src/services/music_misses.py` (`due_filter`, `record_miss`, `clear_miss`, `stats`), `MusicLookupMiss`, `music_matcher.resolve_artist_mbids`, the custodian's `_run_spotify` done-check | A lookup that found nothing is a result (2026-09-20). Phase 1.4 selected on "artist_mbid IS NULL" and wrote nothing on a miss, so the owner's 1,696 unresolvable Spotify artists were re-queried against MusicBrainz every night at ~1 req/s — seventeen minutes a day for a yield near zero, and the done-check read successes so it stamped "drained" each time. Misses now go in their own table with a growing wait (14, 30, 90 days; the empty-string trick the genre phase uses is unavailable because readers treat a non-null id as resolved), the selection skips what is still waiting, a later success clears the record, and the done-check counts attempts. The Music-pipeline tab names the parked count so a bar short of 100 % explains itself. |
| LLM placement (CPU lane) | `src/services/llm_lane.py` (`lane`, `placement`, `available`, `busy_message`, `status`), `llm_utils.ollama_options` / `curator_options`, `process_monitor.game_process_running` / `gpu_pressure_reason`, `Task.llm_role`, `LLM_CPU_LANE` / `LLM_CPU_THREADS` / `LLM_CPU_MIN_FREE_MB` (wizard + Settings → Integrations), app_state `llm_lane` | Where an LLM call runs while another program holds the GPU (2026-09-15). Measured on the owner's card at 17.5/24.5 GB and 90 %: the 19.9 GB curator never got its model server up (304 s, then "timed out waiting for llama-server to start"), while the 5.3 GB summariser did a real enrichment prompt on six CPU threads in 145 s without touching the card. So summariser-class work (enrichment, significance, reception, memory, taste, lyrics profiles) moves to the processor and the library stays current; curator-class work (chat, starters, recommendations, collections, deletion candidates) waits, and the chat answers with a notice naming the occupancy instead of timing out. A detected game still parks everything — its CPU threads are not ours to take. |
| Series editions | `src/services/editions.py` (`classify_files`, `anidb_flags`, `sync_editions`, `edition_line`, `upgrade_rows`, `check_releases`), `SeriesEdition`, custodian task `editions_sync`, `SonarrClient.get_episode_files` / `search_releases`, `GET /api/library/editions/{id}/releases` | Which cut is on disk, and is there an uncensored one (2026-09-15)? Owned from Sonarr's episode files: the parsed quality source of every file (Blu-ray / DVD against web / television) and the names and custom formats that say uncensored / uncut / unrated or censored. Exists from the AniDB tags of the offline snapshot, by AniDB's definitions: "tv censoring" (147 of the owner's series) means the disc release is the uncensored cut, "censored uncensored version" (32) means even the disc keeps censoring. Walker looked at daily (custodian cadence 24 h) with a weekly per-row recheck, one call per series at 60 requests a minute, 200 series per tick; the verified block carries an Edition line for shows and anime, the Curation upgrade list shows "TV cut — uncensored disc release exists" (broadcast or web files on disk while AniDB says the disc is uncensored) with a Search-releases button that asks Sonarr's indexers live (seasons 1–2, uncensored by name or from a Blu-ray/DVD source). |
| Untrusted HTML | `ui.sanitizeHtml` / `renderMarkdown`, `tests/test_untrusted_html.py` | Model output reaches innerHTML through one sanitizer with data attributes off and the dispatcher's own attributes stripped by name (2026-09-25). DOMPurify's defaults kept `data-on-error`, and app.js runs any registered action named there in the capture phase, so an `<img>` in a curator reply could have approved a deletion without a click; the action handlers now encode their path arguments too, and `esc()` escapes quotes. |
| Plex PIN binding, setup-code budget | `auth._bind_pin` / `_check_pin_binding`, `auth.SETUP_CODE_FAILS_*`, `setup_wizard._plex_client_id` | `/plex/pin` hands its creator a nonce that `/plex/poll` must echo, polls are budgeted per client address, wrong setup codes per address and globally, `write_env` refuses values with line breaks, and a fresh install gets its own random `PLEX_CLIENT_ID` while established ones keep theirs (2026-09-25). |
| Deletion claim + verify | `recommendations._transition_if_open`, `_blocked_reason`, `_protection_for`, `_verify_arr_item_gone`, `episodic_memory._ground_protection_actions` | Status and ProtectedMedia are re-read before the Lidarr guard and again right before the DELETE; the row is claimed with a conditional UPDATE committed at once, so one proposal sends one DELETE ever; a delete that times out (read timeout now 300 s) is checked by GET and parks in limbo when unclear; Keep is refused on a closed row; Plex-music proposals get a real `/identity` probe; the protection classifier acts only on the anchor title or titles in the user's own words, three per turn; boot parks interrupted `deleting` rows in limbo (2026-09-25). |
| Back after deletion | `src/services/deletion_returns.py` (`check_returns`, `match`, `open_returns`, `ban_deleted_artist`, `retry_bans`), custodian task `deletion_returns`, `GET /api/recommendations/deletions/returned`, `POST .../{id}/returned/keep` and `/delete`, `DeletionProposal.returned_at` / `returned_info` / `soulsync_ban` | Deleting is not always the end (2026-10-09). The daily check (library reads) looks every deleted proposal up in its library: a TMDb / TVDb / MusicBrainz id match counts whenever the item is there ("still_there" when the library added it before the delete), a title match only when the title is unique and was added after the delete (Skin Deep, The Enforcer and Ghost in the Shell matched other films of the same name). Open returns show in the Deletions view; Delete again creates a proposal for the item the library holds now and runs it like Delete, Keep closes the return for good, and a return that leaves the library again is cleared. Music: SoulSync's playlist sync compares against Plex, so a successful music delete puts the artist on SoulSync's blocklist (`soulsync_client.block_artist`, read back, recorded on the row, a warning when it did not hold); `retry_bans` sets the bans that never held (SoulSync down, a rotated key) every day before the check (owner OK 2026-10-10) and by hand (`python -m src.services.deletion_returns --retry-bans`), and never one that held, so an artist unblocked in SoulSync stays unblocked. |
| Sync watermark, job contract | `plex_sync._run_plex_sync` (`sync_started`), `scheduler._tracked`, `connection._ensure_deletion_proposal_indexes` | `last_sync_at` is the moment Plex was asked, not the finish time, so a play landing mid-sync is not lost; a scheduler job returning `False` is not stamped (failed syncs, busy skips and game-time waits retry next tick, and a mere wait creates no Activity card); the per-entry DB walk, VACUUM and the backup run in worker threads; the Pass-90c rebuild had lost the two `deletion_proposals` indexes, recreated at every start (2026-09-25). |
| Chat runtime guards | `llm_priority.curator_start`, `chat._send_message_guarded`, `_release_unstarted_chat`, `proactive_messages.get_unread_messages(seen=)` | A cancel during the eviction undoes the slot acquire; the deletion funnel releases only a slot it holds; the one-reply-in-flight guard is taken before anything is saved or built and released on every exit, a never-started stream included; Respond leaves a verification question unread until it is answered; the bell's background poll is not an impression (2026-09-25). |
| Member view, budgets, parsers | `tasks._visible` / `_owner_of`, `library_status`, `media_enricher.omdb_quota_left`, `episodic_memory._split_action_line`, `image_proxy._host_resolves_public` | The 2026-09 audit residuals, closed 2026-09-25: a member's Activity view and task stream carry only tasks whose id names them; the library status hides arr addresses, root folders and test results from members; OMDb calls are counted per UTC day in app_state and stop at `OMDB_DAILY_LIMIT` (OMDb's own limit answer marks the day spent); the protection classifier's action line splits only before a known field label, so a title with a pipe stays whole; the image proxy resolves a whitelisted name and refuses private, loopback and link-local answers before every upstream connection. |
| Vector identity | `ChromaDBWrapper.upsert_documents` / `use_collection`, `taste_engine._doc_is_item`, `enrichment._clear_emb_caches`, `media_enricher.ensure_verified_data` | Chroma's `add` keeps the old vector for an existing id without raising, so re-enrichment upserts; a stored vector is used only when its doc's domain and title match the item (doc ids mix TMDB movie, TMDB TV and AniList number spaces); a force re-enrich clears only the requested items' embedding caches; the wrapper switches collection in place after a profile flip; `ensure_verified_data` keeps the ids it resolved for every top-up (2026-09-25). |

### 2026-10 — the second machine, the Wrap-Up and the history under it

| Subsystem | Files | One-liner |
|---|---|---|
| Wrap-Up | `src/services/wrapup_facts.py`, `wrapup_story.py`, `wrapup_store.py` (table `wrapups`), `src/routers/wrapup.py`, `frontend/js/wrapup.js`, custodian task `wrapup_pregen`; design record `docs/WRAPUP_DESIGN.md` | Every household member's own period in review (30 days, 6 months, a calendar year or all time; everything or one medium). It replaced the admin Curation Report, whose ledger is now an admin-only section at its foot. **Code decides every number; the curator only names and phrases them.** `build_wrapup_facts` computes the superlatives, contrasts and traits in SQL over the user's own rows and counts PLAYED time: a ten-minute abandon of a three-hour film is ten minutes, Spotify rows carry it in `view_offset_ms`, imported finished rows get a per-type estimate whose share travels with the figure so the tile can say ≈. A section with nothing real to say is left out, never zeroed: the UI renders only earned tiles and the prompt sees only real facts. The story is a forced JSON shape in which facts, titles, owned-unwatched picks and clusters enter by reference (F#/T#/U#/C#); `check_story` refuses a number that is not on the sheet (0–10 aside), an unknown reference, or a title anchoring two chapters. One repair turn names the fault, then `fallback_story` builds the same shape from the facts alone, so the page never shows an error where a story should be. Facts are reused for 15 minutes and a closed year stays frozen until `FACTS_VERSION` moves: bump it whenever the rows under the facts change meaning (watch history, the clock). A story is stale when its hash differs from `facts_hash`, which ignores posters and drift. One generation per (user, scope, period): the in-process task registry is the claim (no await between check and create), so a crash leaves no stale lock. "Ask about it" is a chat thread (`wrapup:{scope}:{period}`) whose hidden context is the same fact sheet. The custodian writes only what `due_wrapups` names (the rolling month weekly, the year from 1 December, the closed year once more in early January). |
| Compute nodes (the second machine) | On the node: `src/pc_agent.py`, `src/gpu_engine.py`. On the server: `src/services/pc_host.py`, `pc_guard.py`, `ollama_hosts.py`, `verdict_cache.py`, `src/routers/hosts.py`, `process_monitor.pc_busy`, the per-host lanes in `llm_priority`. Settings `PC_AGENT_*`, `OLLAMA_PC_ENDPOINT`, `ENRICH_ON_PC`; operator docs `docs/USAGE.md` ("Running in a container"), `docs/NAS_MOVE.md` | Curatarr on an always-on NAS borrows the gaming PC's card while it is on and nobody plays. **The agent decides nothing:** it reports raw readings (process names and memory, nvidia-smi, each program's 3D-engine share, its checkout's update state, its node name), and which program is a game stays in `process_monitor` on the server, so that logic exists once. `pc_host` polls every 5 s: available after one good answer, gone after two missed ones (one dropped packet must not flip the routing; a powered-off PC must not hold work for an HTTP timeout), draining at once on the agent's goodbye or its `shutting_down` flag. `generation()` goes up each time the node stops taking work (gone, draining, busy). **Routing picks a host by role, never a model name**; the bakes carry the same names on every host. Unsplit, every role answers the one endpoint; on a split install `host_for(role)` sends chat and curator work to the node while `pc_usable()`, the judge only there (`PcUnavailable`: a deletion run waits rather than judging with the small model), embeddings always to the main host (one host for every vector, so stored and new ones compare), enrichment to the main host unless `ENRICH_ON_PC`. A curator call takes its URL and its options from the same `host_for` result, because the card decides the window and the constitution variant (`LLM_PROFILE`). `pc_guard.ollama_client()` races every request to the node against `generation()` and raises `PcLost` the moment it changes. `PcLost` is an `httpx.ConnectError`, so every caller already handles it as "Ollama unreachable": a verdict fails closed, an item stays due, an unstarted chat reply may retry on the main host. `llm_priority` keeps one queue per host, so work waits only for work on the same card. Without the agent the node is never used blind. `verdict_cache` keeps a verdict under a hash of everything that decides it (bake digest, the judging host's profile and window, the system prompt with the learned principles, the evidence), so the node's free hours are not spent re-judging unchanged titles; outages are never stored. `PC_AGENT_ONLY` makes the node's tray run only the agent, and a server started there refuses, so Plex and the arrs are never synced twice. `/api/hosts/pc/goodbye` authenticates with `PC_AGENT_TOKEN`, not a login. |
| Updates and build identity | `src/self_update.py`, `src/services/build_info.py`, `update_check.py`, `model_autobuild.py`, `GET /api/system/version`, `POST /api/hosts/pc/update`; `UPDATE_CHECK`, `AUTO_BUILD_MODELS` | **An update only fast-forwards.** A desktop checkout moves to `origin/main` only when it is on main, has no changes to tracked files and no commits of its own; nothing is merged, stashed, reset or overwritten, and a refusal says why. No URL, ref or command comes from outside: the node's update request carries only the token, and its agent fetches from its own `origin`. `build_info` names the running build (the image's `CURATARR_BUILD_SHA`, else HEAD read from `.git` without a git binary). `update_check` asks GitHub anonymously at most hourly and counts "newer" only when GitHub's compare puts upstream AHEAD, so a dev branch is never told to update; an answer stored for another sha is not shown. The version endpoint never waits on GitHub (stored answer plus a background refresh). In the container `model_autobuild` builds the main host's missing bakes in the background after start, so the server answers at once and model calls fail closed until the bakes exist; the node's bakes are only named, never pulled onto a machine someone games on. |
| Whose clock | `src/services/user_time.py`, `users.timezone`, `POST /api/users/me/timezone` | Play rows are naive UTC. Local time is the user's IANA zone (the browser reports it; unknown names are refused), else the machine's (TZ, then /etc/timezone, then today's fixed offset), applied with **the offset in force at each row's own time**. One current offset over a whole span moved plays across DST and put a UTC container's evenings in UTC. SQLite has no zone data, so `local_sql` carries the zone's offset changes over the queried span as a CASE (exact, a handful of branches a year); the Wrap-Up converts through it, the sittings' dayparts through `to_local`. |
| Library ledger | `src/services/library_ledger.py`, tables `library_items` / `library_events`, written by the 12-hourly tech walk in `plex_sync` | `MediaTechProfile` is a snapshot (overwritten, pruned when a title leaves); the ledger is the history on both sides of the storage balance, titles deleted outside Curatarr included. `library_items` keeps every title ever held with its earliest Plex `addedAt`. `library_events` is append-only: arrivals back-dated from `addedAt` (one per title and month, so the existing library's history is known from the first walk), gone and returned, and the size changes arrivals don't explain (grew, shrunk). **Only a complete walk marks anything gone**, the tech-profile prune's guard, so an outage or a cut-short walk never empties a category. The ledger writes in its own transaction: a fault there never costs the profiles. `velocity()` (GB in against GB out, by month) feeds the Wrap-Up. Music is not walked by default, so it has no ledger. |
| Debate outcomes | `src/services/debate_outcome.py`, the `curator_resolution_log` columns `proposal_id` / `score` / `verdict` / `storage_mb` / `thread_id` / `user_turns` / `persuader` / `persuader_basis`, custodian task `debate_outcomes`, `GET /api/stats/debate-labels` | A resolution row says what was at stake and who moved. `persuader` is decided by rule wherever the facts decide it: an override, or a debate in which the user never wrote, moved nobody; a proposed cut kept by consensus means the curator conceded. Only a delete by consensus after the user spoke needs the thread read. The summarizer answers that in the custodian job for new and old rows alike, so every label comes from one classifier. `safe_fields_for` never costs the log row it decorates: on a fault the row is written bare and `link_old_rows` fills it later (by user, title and outcome near the row's time). The debate-labels endpoint lets the owner check the labels against the debates. |
| Model updates | `src/services/model_updates.py`, `model_update_eval.py`, `src/routers/model_updates.py` (`/api/system/model-updates`, admin), scheduler jobs `model_update_check` / `model_update_recover` (§13), app_state `model_updates`, Settings → Maintenance | A `curatarr-*` bake carries the weights it was built from, so pulling a tag never changed what runs: the installed copy was a pin nobody could see, and nobody noticed when a tag got new weights. The daily check compares each bake's RUNNING weights (the first `FROM` blob of `/api/show`, which a remote Ollama answers too) with the registry manifest's model layer. The curator and the pitcher can be tested; the summariser gets the notice only, and the embedding model is never offered (new weights would mean re-embedding the library). It manages and measures this install's own Ollama (`OLLAMA_ENDPOINT`), never the node, whose bakes `build_models.py --host pc` builds. **No pin, no pull:** the running weights are copied to a `-pinned-<digest>` tag and read back before anything is downloaded, a pull of unknown size is refused on a local Ollama (disk room fails closed), and `test_started` is saved before Ollama changes, so a test a restart cut off is rolled back at the next start. Every rollback step is checked against what Ollama then holds, and the pin is deleted only once the tag holds the running weights again; until then it is the last copy. Both bakes judge the same evidence for ten library titles plus three chat questions under the curator gate, timed through `/api/chat` (the path production uses; `/api/generate` measured the same weights three times slower). The review is the current weights without the persona, blind (A/B) and in both orders, a preference counting only when it survives the swap, because it is judging its own successor. **Measurements overrule it:** more failed calls, a CPU spill or generation below 75 % of the old speed turn its "update" into "stay" (`guards`). "Stay" restores the pin and mutes that registry digest until it moves again; "update" rebuilds the bakes and keeps the pin when a rebuild fails. |
| Swipe | `src/services/swipe.py`, `src/routers/swipe.py` (`/api/swipe`, every signed-in user), table `swipe_ratings`, `taste_engine` (swipe entries join the weighted average), `frontend/js/swipe.js` | The taste vector only knew what Plex saw: a film seen in the cinema or a series watched on Netflix never reached it. The stack is the library titles nearest the user's taste centroids (a category-filtered vector query) that they have neither played in Plex nor answered, one card per title (a title's Plex and arr docs are twins); a loved title pulls its own nearest unanswered neighbours forward (`chain_cards`). **Only a seen answer is taste signal:** 10 stars weigh +1.5 and 1 star −1.5, linear between, seen without stars +0.5. "Not seen" only keeps the title out of the next stack. "Want to watch" is intent, not experience: it goes on the user's plex.tv watchlist and never into the vector, since the stack is built from the vector and wanting its nearest titles would only pull the vector toward itself. A title the user's own plays already weigh is left to them. A new answer replaces the old as a NEW row, because undo orders by id (the Windows clock's ~15 ms tick tied timestamps). Every `REBUILD_EVERY` (10) seen answers in a category rebuild that category's vector in the background without LLM summaries; answers shape only their own user's vector. Since 2026-11-08 (UX design record, stage 2): the client answers in **decks of 10** (the ring counts the deck; its end screen says what went on the watchlist, what was loved and how far the next refresh is, with "Another 10" / "Done for tonight"); `diverse` keeps three of one genre from following each other and moves to another taste cluster after four from one; a card names ONE reason, `attach_anchors`: the loved title (a "Loved it" verdict, a Rediscover title) or recent finish (the because lane's anchors) nearest it by centred cosine (≥ `HOOK_MIN`), else its cluster's titles; `pitch` gives it one line, the synopsis opens on a tap. **Calibration:** without a profile in the category, `/next` answers `reason: "calibration"` with `calibration_cards` - the titles nearest the category's corpus mean (cached six hours), best-rated first, no other member's history - and the fifth seen answer (`CALIBRATION_FIRST_BUILD`) builds the first profile; the rebuild keeps a category with no plays but Swipe answers (`swipe_count`), and stores it from five plays plus seen answers. |
| Post-watch verdicts | `src/services/post_watch.py`, table `post_watch_verdicts`, `POST /api/messages/{id}/verdict` (the member's own messages), `proactive_messages.followup_card`, `taste_engine` (`factors_for` / `factor_of`, `_series_entries(..., verdicts)`), `frontend/js/chat.js` (the landing card) | The landing's follow-up card asks "how did it land?" about ONE film or series the member played: a watched recommendation, or a morning line about exactly one title (`detect_last_night` stores `parts`, `titles`, `subject`; music follow-ups and lines about several things stay in the bell). A tap stores Loved / Liked / Okay / Not for me. **A verdict is a weight on the play that is already in the history, never a row of its own**, so nothing counts twice: a film matches by its play's key, a series by its title (the rebuild weighs a series as one). It replaces the completion and drop guesses for that play: factors on the music rating's scale (10/6, 8/6, 5/6), and "Not for me" is −1.0, a push, because a finished film at any positive factor would still pull. One row per work (a new answer replaces it); the answer retires the message and rebuilds that category's vector in the background without LLM summaries. `swipe_ratings` stays the record of what was seen outside the server. |
| Tonight candidates | `src/services/tonight.py` (`candidates`, `block`), `chat_prompt.build_system_prompt(tonight_context=)`, `chat_starters.active_series` | "What should I watch tonight?" searched the vector store for that sentence. The chat prompt now carries a `[TONIGHT CANDIDATES]` block from data already kept, no model call: the series the member is in the middle of (three plays, the last within a week, the openers' own rule) with how far they are (`series_progress`), at most four stored picks in the library lanes in turn (because, library, rediscover; the because lane names its three anchors; the stored reason cut to 160 characters) and at most two Swipe "want to watch" answers from the last half year. A title played since it was stored drops out (rediscover: a play since it was stored). Films, series and anime; music only when the turn's domain is music. It rides only the turn the UI starts as the evening question - `discuss_context` kind `tonight`, sent by the clock chips (`chat_starters.user_chips` marks them) and the Tonight card's "Ask the curator" (`tonight_card` gives its question) - which is free chat in every other way (the general thread, no discuss block, no banner). A typed message carries none: with the bare list on every turn a targeted ask answered with the same stored pick (owner's measurement on real data). No filter reads the message text; rule 8 shapes the answer. |
| Tonight card | `src/routers/home.py` (`GET /api/home/tonight`, `POST /api/home/tonight/watchlist`, every signed-in member), `tonight.tonight_card` / `_moment` / `headline` / `member_why`, `frontend/js/chat.js` (`#tonight-panel`) | The same candidates as the chat block, ordered for the member's moment (their zone): weeknight evening a series in progress, Friday/Saturday evening a film, Sunday day a rediscover pick, late a series, daytime a Swipe want. The lines are templates whose every slot is a fact, in en/de by `user_language`; the second line (`member_why`) is facts only - anchors, year, genres, Rediscover's own fact line - never the stored pitch or the candidates' model-facing wording. A series in progress links its ledger key (the series), not the last episode. Links: Plex through `app.plex.tv/desktop` with the machine id (a failed lookup is remembered `MISS_TTL_S`, so a Plex outage does not make every GET wait for the timeout), so a shared account away from home can open it; Jellyfin through the adapter's `deep_link`. The watchlist add is the Swipe "Want to watch" write. |
| Loved lifts | `src/services/rec_lift.py` (`lift_near`, `nearest`), `cached_recommendations.lifted_at` / `lifted_by`, the Swipe `/answer` (10 stars) and `/api/messages/{id}/verdict` (`loved`) | A Loved moves the member's stored picks nearest the loved title up, at once: at most `LIFT_MAX` (3) from the library and because lanes, by centred cosine (≥ `LIFT_MIN`) on the Chroma vectors found through the enrichment keys (a Swipe card by its own doc id). A re-rank of the rows that exist, no model call: the Recommendations GET, the playlist push (`_library_recs` / `_because_recs`, whose `cached_at` becomes the later of caching and lift, so the push is due) and the tonight candidates order by `lifted_at` first; the weekly regeneration rebuilds the rows. The answers return `lifted`, the titles that moved, so the UI only claims what happened. |
| Title actions | `src/services/title_actions.py` (`bold_titles`, `match`, `for_reply`), `src/services/server_links.py` (`attach_links`), `src/routers/home.py` (`POST /api/home/watchlist`, `POST /api/home/keep`), the chat stream's `titles` frame, `frontend/js/chat.js` (`_titleRow`), `recs.js` | After a reply is complete and saved, its `**bold**` spans that ARE a library title (normalized equality against the chat's own arr-cache index, not containment; at most four) are sent as one `titles` frame before `done`, each with the library ledger's server key turned into a link and whether this member already keeps it. The row: Open on the server, + Watchlist (the Swipe "Want to watch" write), Keep (a `ProtectedMedia` row, `source` manual, idempotent - the judge reads a member's keep as the household's). A failure costs the row, never the turn. Owned recommendation cards carry the same link and watchlist add. |
| Wrap-Up weeks, months and narrative | `wrapup_facts.resolve_period` (`week:YYYY-Www`, `month:YYYY-MM`, `week_key`, `month_key`), `src/services/wrapup_narrative.py`, `wrapup_insights._narrative`, `scripts/wrapup_read.py` | A week and a month are windows from the member's local midnight (`user_time.to_utc`), with the period before as their comparison. The narrative facts (ritual, comfort genre, of the month, night shift, the period's first title) come from one SQL over the timed plays in the member's zone and enter the editor as cards; a week or month story is the strongest `MINI_STORY_CARDS`. `scripts/wrapup_read.py` prints the facts, cards and fallback texts of a database copy (with `--write` the written story) and writes nothing. |
| Wrap-Up claims | `src/services/wrapup_claims.py` (`TEXTS`, `render`, `claim`, `headline`, `warm`, `num`), `wrapup_insights._card(key, slots)`, `wrapup_writer._template` / `stray_placeholders` | Every insight card names its fact (`key`) and carries its parts (`slots`); its claim is rendered from them: the English sentence is the one the editor reads, the German one says the same with German words and numbers. A slide the writer could not write says the claim with that fact's own headline and warm line (none where none fits), in the story's language. The editor and writer see a card's renderings without their field names, and a section that says one (`placeholders`) goes back for repair. |
| Wrap-Up moments | `src/services/wrapup_cards.py` (`week_card`, `month_card`, `year_card`, `moments`), `GET /api/home/moments`, `frontend/js/chat.js` (`#moments-panel`) | Cards on the chat landing at the moments they belong to, on the member's clock: "Your week" Sunday from 17:00 to the end of Monday (facts only, the week's facts cached 30 minutes, only for an eligible week), "Your month is ready" in a month's first `MONTH_STORY_DAYS` once its story is stored, "Your year is ready" from 1 December to 14 January once written. Reading never generates: the month's mini story is written ahead by `wrapup_pregen` (`due_wrapups`). Words in en/de by `user_language`; "Not now" hides a card in the browser. |
| Wrap-Up playlists and share images | `routers/wrapup.push_playlist`, `plex_playlists.push_wrapup_playlist` / `jellyfin_writes.push_wrapup_playlist` (`name`, `aliases`), `playlist_names` (`wrapup_week`, `wrapup_month`), `server_links.attach_links(playlist=True)`, `frontend/js/wrapup.js` (`playlistCardHtml`), `frontend/js/wrapup_story.js` (`shareWrapupSlide`) | A push answers each playlist's key, turned into a link that plays it; a week's and a month's playlist is one rolling playlist in the member's language, the other language's name renamed in place. A slide's share image is drawn on a canvas in the browser from its `share` line, the period label and the wordmark, and goes to the share sheet or a download; nothing is uploaded. |
| Credits index | `src/services/credits_index.py`, tables `media_works` / `media_credits` / `media_companies`, custodian task `credits_index` (daily, no model), `media_enricher.fetch_tmdb_full` (`credits` / `companies` / `collection`) | Directors, writers, cast, studios and networks lived only as JSON in the raw API cache (`raw:{cat}:{id}`, 90-day TTL): not queryable, and gone for a title deleted in March by the time a December wrap-up asks for the most-watched director. The walker files every live raw document of the video categories into durable tables, and **the raw cache expiring never removes an index entry.** A title's data is spread over several raw rows (doc id, external ids, title): rows sharing any id are one title, filed under its best id (TMDB for films and shows; AniList, else MAL, else TMDB for anime) with the credits of the richest row (structured ids beat bare names) and the companies of all, and an entry an earlier walk filed under another of its ids is folded in. Older fetches carry names only, which join across titles by `name_key`; TMDB's anime cast is filed as `voice`. An unchanged title is skipped by fingerprint, and titles are written 300 at a time so no write transaction holds the single writer long. |
| LLM failures by kind, prompts that fit | `src/services/llm_errors.py` (`post_chat`, `LLMFailure`, `INFRA` / `REPAIRABLE`, `DESCRIPTIONS`), `src/services/prompt_budget.py` (`fit`, `fit_messages`, `keep_tail`), `pillars.build_evidence` / `adjudicate`, chat history, the deletion run's outage stop in `recommendations_engine` | Every judge failure used to end as the same EVALUATE and "the model did not answer": Ollama down, a model that could not load next to a game, an over-long prompt, a cut-off answer, unreadable JSON and a fault in our own evidence code looked alike, and each needs a different response. `post_chat` raises `LLMCallError` carrying the kind: infrastructure kinds count as outages (two in a row stop a deletion run, and an outage does not spend the run's cap), parse, schema and truncation get exactly one repair turn that shows the model its own answer, and the Deletions banner names the cause. It also reads Ollama's own accounting (`done_reason`, `prompt_eval_count`), because **Ollama truncates an over-long prompt silently, from the front, where the system prompt is.** So a prompt must fit before it is sent: `fit` trims or drops the least valuable sections first (priority 0, the title, owner, household and feedback lines, is never touched; a one-line section goes whole or not at all; a trimmed untrusted block keeps its END fence, or the rest of the prompt would read as data), chat drops its oldest turns (an answer goes with its question), and the judge refuses a prompt its pre-flight estimate says will not fit. Tokens are estimated at 3.2 characters each, erring toward fitting; `prompt_eval_count` is the check after the fact. |
| Limbo reconcile | `src/services/limbo_reconcile.py`, custodian task `limbo_reconcile` (daily), `recommendations._delete_one_and_log` / `_DELETE_CLAIMS` | `limbo` means a delete was sent (or due) and nobody knows how it ended. A row used to leave it only on a Retry click, so a delete that had gone through sat there with no resolution-log row and no freed-storage credit. Daily, per limbo row: a 404/410 from the arr or Plex takes the Retry path (which already reads 404 as "the earlier delete went through"), so the outcome, the log row and the SoulSync ban are written exactly as a click would; an item still present stays in limbo with at most a weekly bell to its user, because **the owner approved a delete, not an unattended re-send: nothing here sends a DELETE for an item that still exists**; no answer leaves the row for tomorrow. It also turns `deleting` rows that no delete of this process holds into limbo, but only rows already `deleting` on the previous pass: a delete claims its row on the writer thread before it can note the claim, and no real delete lasts a day. |
| Retention | `src/services/retention.py`, custodian task `retention` (daily) | Soft retirement keeps ids stable and leaves a trail, but nothing ever removed the retired rows, and each rode along in all seven daily backups (the daily ARR sync alone supersedes a full proposal set). Removed, and nothing else: proposals superseded over 90 days ago, read proactive messages over 90 days old, conversation starters used or expired over 30 days ago, and OMDb day counters older than a week. **A proposal or message that a chat thread points at is kept.** That test builds the thread id with SQL's string-concatenation operator on purpose (`_thread_of`): `prefix + cast(id)` can compile to SQLite's arithmetic `+` (`'deletion_proposal:' + '42'` is 42), which matches no thread and would delete exactly the discussed rows. Kept on purpose: `deleted` / `rejected` / `error` proposals (tombstones the returns check and the catalog sync read, and the owner's keep decisions), chat, memories, the resolution log. Hard deletes are safe because `deletion_proposals` is AUTOINCREMENT, so a freed id is never handed out again (§15). |
| Vector reconcile | `src/services/vector_reconcile.py`, custodian task `vector_reconcile`, `boot_drift_check` in `main.lifespan`, `scripts/revectorize_backfill.py` (deprecated; it drains this pass) | State lives in three stores, curatarr.db, the cached `enriched:*` profiles and the Chroma vectors, and the daily backup covers only the first. A database restored over a wiped or older vector store went unnoticed: the enrichment pre-filter skipped every enriched row, the audit heals docs that lost their profile but never profiles that lost their doc, and the stats counted `vector_ready` from SQLite, so the UI read "fully indexed" over an empty collection. The pass joins the three by the doc id `store_profile_vector` writes (`plex_rating_key or tmdb_id or anilist_id or title`). A missing doc whose profile is cached is re-embedded from it (no summariser; one embed per profile however many rows share it; 300 a run, and the task stays due while any remain). A row with no readable profile goes back to enrichment **once**: one that re-enrichment did not cover is reported, not looped. `vector_ready` is corrected both ways. **Never destructive:** it adds vectors and re-queues rows, it deletes nothing, and a Chroma read that fails raises instead of reading as "every doc is missing". A restored database carries its own last-run stamp, so at startup two counts decide whether the first tick reconciles anyway; while fewer than half the enriched rows have a doc, the owner is told weekly. |

---

## 1. One-paragraph overview

Curatarr is a single-tenant FastAPI app that sits between a Plex server, the
*arr stack (Radarr/Sonarr/Lidarr), external metadata APIs (TMDB, OMDb,
AniList, MusicBrainz, Last.fm, Spotify, MyAnimeList (official API v2 with a
client id, else Jikan) — plus OpenSubtitles, by id
only and only when a key is configured), and a locally-hosted Ollama
LLM. It pulls each Plex user's watch history, enriches every title with
metadata + an LLM-written profile, embeds those profiles into a vector store,
and uses the resulting per-user "taste vector" to (a) recommend new media,
(b) propose deletions of media that no longer fits, and (c) hold a
character-driven chat about any of it. Every prompt runs locally; the only data that
leaves the machine is what the enrichment pipeline asks public metadata APIs,
and most of those are searched by NAME, not by id — so they learn which
titles and artists the library holds. Watch history, ratings, the taste
vector and anything typed stay on the machine.

---

## 2. Process lifecycle

`src/main.py` is the entry point. On startup (`lifespan`):

1. Creates `data/chromadb/` + `data/cache/` dirs.
2. `init_db()` — creates tables + runs idempotent column/table migrations
   (`src/database/connection.py`).
3. `start_scheduler()` — registers APScheduler cron jobs (see §13).
4. **Resets stuck runtime flags** — `enrichment_running`, `deletion_run`,
   `plex_sync_running` → `"0"`, each on its own and read back (ERROR when
   one stays set). `music_pipeline_running` is a cross-process lease
   (`src/services/state_lease.py`): `reclaim_if_stale` clears it, and then
   `music_pipeline_stop_requested`, only when its holder (e.g.
   `scripts/music_enricher.py`) is no longer running. Seeds `game_active`
   with the *actual* current game state, probed on a worker (not a blind 0,
   which left a race window; a failed probe counts as a game). This is the
   recovery path for a crash that left a flag set.
5. `prewarm_arr_caches()` — loads the ARR library cache from disk (L2→L1)
   and fires background refreshes (see §12).
6. Mounts routers (each under `/api/<name>`).

`apscheduler.executors.default` is bumped to WARNING level so the per-job
"Running/executed" chatter doesn't drown the logs (the Game-mode watcher
fires every 30 s).

---

## 3. Data model (`src/database/models.py`)

SQLite via SQLAlchemy 2.0, WAL mode. The tables that matter:

| Table | Purpose | Notes |
|---|---|---|
| `users` | Accounts | `is_admin`, `is_active` (soft-disable, never DROP), `timezone` (IANA, reported by the browser; §0 Whose clock) |
| `watch_history` | Every playback event | per Plex `accountID`. `source` = plex/spotify/manual. `plex_item_id` rewritten from `spotify:track:…` to a Plex key when Phase 1 matches. `artist_mbid` filled by Phase 1.4. `genres` filled by Phase 1.5/2. |
| `enrichment_status` | One row per enriched item | THE pipeline state table. `plex_rating_key` is the UNIQUE key. See §5 for the six states encoded in `(enriched, error)`. Phase-2 columns (Pass 99-fu13, #37): `fetch_tier` (`"fast"`/`"full"`/NULL=full-legacy), `sources_state` (JSON of per-API status + timestamp), `provisional` (denormalised "still upgradable" flag). Read-side default for NULL `fetch_tier` is "full" — back-compat with pre-fu13 rows. Index `idx_es_fetch_tier` keeps the upgrade-scheduler query (#41) cheap. |
| `arr_enrichment_status` | ARR-side enrichment mirror | written for items that have an `arr_id`+`service` |
| `media_identity` | Cross-ref ID store | `plex_rating_key` → tmdb/tvdb/anilist/anidb/mal/imdb. Canonical ID override for the enricher. |
| `taste_vectors` | Per-user taste (legacy/plain) | genre/actor/director affinity JSON + `summary_text` |
| `encrypted_taste_vectors` | Per-user **per-category** taste | the active one. `encrypted_blob` is plain JSON — the name is historical; the planned PIN-based encryption was removed as unworkable (background jobs need the vector when no user is present to supply a PIN) and disk-level encryption is the honest answer for at-rest. One row per `(user, media_category)`. |
| `deletion_proposals` | Deletion candidates | `id` is AUTOINCREMENT (Pass 90c — never reuse ROWIDs, see §15). `status` pending/approved/rejected/deleted/superseded. |
| `cached_recommendations` | Generated recs | wiped + replaced per category on regen |
| `proactive_messages` | Curator-initiated nudges | `trigger_type`, `read` flag |
| `conversation_messages` | Chat history | `thread_id` isolates topics: `general` / `deletion_proposal:{id}` / `proactive_message:{id}`. Drives language detection. |
| `episodic_memories` | Extracted taste/feedback/protection memories | `memory_type`, `media_category`, vector-searched globally per user |
| `protected_media` | Deletion whitelist | `identifier` = TMDB/AniList id OR exact title |
| `protection_history` | What a protection was | its state before it was lifted, changed by the judge or downscaled (`services/judge_record`) |
| `music_tags` | Plex's moods and styles | one row per artist / album / track, kind and tag; a complete walk of a section replaces its rows (`services/music_tags`) |
| `judge_verdicts` | The judge's verdicts, kept | one row per title and change of verdict, with the findings, score and size; the same verdict again only counts `scans` (`services/judge_record`) |
| `curator_resolution_log` | Audit trail | consensus-vs-override classification of deletion decisions, plus the proposal, its stakes and `persuader` (§0 Debate outcomes) |
| `wrapups` | One row per (user, scope, period) | last computed facts + last written story with the `facts_hash` it was written for (§0 Wrap-Up) |
| `library_items` / `library_events` | Library ledger | every title ever held; append-only arrivals / gone / returned / grew / shrunk (§0 Library ledger) |
| `app_state` | Runtime KV flags | DB-backed so they survive restarts. See §11. |
| `plex_rating` | Per-user Plex star ratings | music-only signal for deletion hard-protect (Pass 82c) |
| `swipe_ratings` | Swipe answers | one per (user, category, doc id): `seen`, `stars` 1–10, `want`; a new answer replaces the old as a new row, so undo orders by id (§0 Swipe) |
| `post_watch_verdicts` | Post-watch verdicts | one per (user, category, work): `item_key` (the judged play's key), `title`, `verdict`, `source`; weighs the existing play row in the taste rebuild (§0 Post-watch verdicts) |
| `media_works` / `media_credits` / `media_companies` | Credits index | one row per title under its best id, the people and companies on it; durable, while the raw cache it is built from expires (§0 Credits index) |

---

## 4. Storage layers

There are **four** distinct stores. Conflating them is the source of several
historical bugs.

1. **Main DB** — `data/curatarr.db` (SQLite WAL). All tables above.
   Connection pragmas: `journal_mode=WAL`, `synchronous=NORMAL`,
   `busy_timeout=15000` (`connection.BUSY_TIMEOUT_S`), `temp_store=MEMORY`,
   `foreign_keys=ON` (`_repair_foreign_keys` cleans orphans at boot first).
   Pool: 10 + 10 overflow, `pool_timeout=10`. `get_db_session()` context
   manager commits on exit. The event loop never waits on SQLite:
   coroutines run DB work through `run_read` (worker pool) or `run_write`
   (the one writer thread, `write_session()` = `BEGIN IMMEDIATE`) in
   `src/database/runner.py`; `src/database/loop_guard.py` logs any statement
   that runs on the loop thread (`GET /api/system/loop-guard`;
   `CURATARR_LOOP_GUARD=raise` in tests), and `tests/test_loop_db_ratchet.py`
   checks it statically. That is why the timeout can be 15 s instead of 60 s:
   a lock wait blocks one worker thread, not the server.

   The rules behind it. **Never `await` inside a write transaction**: a
   coroutine suspended while it holds SQLite's write lock cannot resume to
   commit while another statement on the same loop waits on that lock, so
   the wait is a deadlock until busy_timeout ends it with "database is
   locked" (the music matcher did this). `run_write` has exactly one thread,
   so writes made through it never contend with each other; busy_timeout
   only arbitrates against writers outside it (another process, a raw
   connection). Cancelling
   the awaiting coroutine does **not** abort a write that already started:
   it commits or rolls back whole on the writer thread, so a cancelled
   caller must not assume nothing was written. Shutdown calls
   `shutdown_writer()` to finish every queued write before the WAL is
   checkpointed and the engine disposed. The guard covers the other SQLite
   files too (the enrichment cache, the lyrics and offline indexes) through
   `loop_guard.sqlite_connect`. Its modes (`CURATARR_LOOP_GUARD`): `warn` by
   default (each site logged once, every statement counted), `raise` for
   tests that pin a path loop-free, `off`. The lifespan's own startup and
   teardown are counted under `lifecycle` instead of logged (`serving()`
   flips around its yield), because no request can be waiting then;
   `tests/run_all.py` collects every suite's sites through
   `CURATARR_LOOP_GUARD_DUMP` and fails the battery on any `src/` coroutine
   site.

2. **Enrichment / API cache** — `data/cache/enrichment.db`, separate SQLite,
   wrapped by `src/cache/metadata_cache.py` (`MetadataCache`). Single table
   `api_cache(cache_key, response, created_at, expires_at)`, WAL with the
   same 15 s busy timeout as the main DB. Every key is
   auto-prefixed with `_CACHE_VERSION` (currently `"v2"`). Bump
   `_CACHE_VERSION` only when the *meaning* of cached values changes — old
   rows then become invisible (read-time `expires_at` filter + version
   prefix). There's a Pass-95 read-only fallback to the un-versioned key
   for one legacy migration case. See §12 for the key namespaces.

3. **Vector store** — ChromaDB at `data/chromadb/`, wrapped by
   `src/vector_store/chromadb_wrapper.py`. One document per enriched item,
   id = `{service}:{arr_id}` or the plex/tmdb id. `count_by_id_prefix()`
   gives per-service coverage. Embeddings from `nomic-embed-text` via
   `src/embeddings/embedding_generator.py`. **Gotcha:** ChromaDB returns
   embeddings as numpy arrays — never test them with `if embedding:`
   (raises on multi-element array); use `is not None` (Pass 74).

4. **AppState flags** — rows in the main DB's `app_state` table, via
   `src/services/app_state.py`. `get_state`/`set_state` route through
   SQLAlchemy; `force_set_state` (Pass 89b) opens a *fresh* sqlite3
   connection with a 120 s busy-wait and `isolation_level=None` to write
   even when the SQLAlchemy engine is in a write-lock cascade — the
   cleanup path that prevents permanently-stuck flags.

---

## 5. The enrichment pipeline (`src/routers/enrichment.py` + `src/services/media_enricher.py`)

The most intricate subsystem. Read this section before touching either file.

### 5.1 The EnrichmentStatus states (one classifier — `services/enrichment_state.py`)

`(enriched, error, provisional, next_retry_at)` plus cache liveness encode
ONE mutually-exclusive state per item. `classify_enrichment_row(row,
has_live_cache)` is the only classifier — the KB tile, `/library/breakdown`
and the drilldown all call it; `STATE_DEFINITIONS` (label / explainer /
next step) is served in the overview payload so the frontend carries no
copy. Status-derived states come FIRST; cache liveness only decides
enriched vs enriched_dead (before 2026-09 a live not-found sentinel was
counted as enriched because the cache check came first):

| State | DB condition | Meaning |
|---|---|---|
| **ignored** | `error LIKE 'ignored%'` | The owner accepted the gap. Held back by the pre-filter, excluded from the open count. |
| **processing_error** | `enriched=False, error IS NOT NULL` | The pipeline (or the LLM) failed and recorded it — `Processing failed` used to crash the writer instead of being written. Always retried. |
| **queued** | `enriched=False, error IS NULL` | Tracked but not (re-)processed yet: freshly seeded, admin-reset, or API data waiting for the polish. |
| **not_found** | `error LIKE 'Not found%'`, `next_retry_at > now` | Every consulted source missed (`Not found: no_source_data`), or the only hit was the wrong year (`year_mismatch`) / too far off (`low_confidence`). Waiting on the backoff. |
| **retry_due** | `error LIKE 'Not found%'`, due | Same rows once their wait is over — the next run tries again. |
| **awaiting_llm** | `error LIKE 'api_cached%'` | API data persisted, LLM paused (game mode). |
| **rule_based** | `error LIKE 'rule_based%'` | Heuristic profile; LLM upgrade retried next run. 1-day cache TTL. |
| **enriched_dead** | `enriched=True, error IS NULL`, no live cache | Flag says done, the profile rotted — revived by the pre-filter. |
| **enriched_provisional** | `enriched=True, error IS NULL, provisional=1` | Fast-tier profile; the finalizer / #41 scheduler promotes it. |
| **enriched** | `enriched=True, error IS NULL` | Full profile, live cache. |
| *never_processed* | no row | denominator − tracked (a live profile under a historic key still counts as enriched). |

`_write_enrichment_db(item, profile, cat, *, fresh_attempt)` is the single
writer and owns the vocabulary above (`ArrEnrichmentStatus.error` is
mirrored). A not-found sentinel written by a FRESH producer round counts an
attempt: `attempt_count`, `last_attempt_at`, `next_retry_at` (backoff:
attempts 1–2 due on the next run, then 3 → 6 → 12 → 24 days, capped at
30 days, for ever — owner decision, no terminal state). A cache reconcile
never counts; a real profile resets the counter. The Step-5 pre-filter holds
back rows that are not due (and ignored rows) unless `force=True`; a
category that aborts on the outage guard gets its attempts rolled back.
Sentinels persist their `sources_state` (per-source `ok | miss | transient
| skipped`, each `ok` entry carrying the entity the source resolved to)
plus a reserved `_context` (arr year, ids we had, the rejected hit) —
`open_reason()` turns that into the one sentence the UI shows.

Match quality: `match_basis` (pin | arr_id | identity | title_search) and,
for title searches, `match_confidence` from the resolving source's own
title/year vs the arr's (`match_score` / `match_decision`: ≥ 0.8 accept,
0.5–0.8 accept and flag for review, below that with a disagreeing or
unknown year refuse — `Not found: low_confidence`).

### 5.2 Producer / consumer

`/api/enrichment/start` builds an item list, then runs a producer + consumer
across **per-category** `asyncio.Queue`s (no longer a single shared FIFO):

- **Item collection**: watch_history rows (if source includes `watch_history`)
  + ARR collect (if source includes `arr`). Dedup by lowercased title per
  category. Then a pre-filter drops items already `enriched=True, error IS
  NULL`. Then a priority sort moves never-enriched fresh items to the front.
- **ARR collect** (`_collect_arr_items`): 60 s timeout (Pass 99-fu7 — the
  full `/api/v3/movie` + `/api/v1/artist` payloads are 15-30 MB and time
  out at the old 10 s under load). Failures use `_format_arr_error` so the
  log isn't an empty string. Radarr items carry `imdb_id` (enables
  OMDb-primary, §5.3); Sonarr items currently do not (known gap — shows
  fall back to TMDB-primary).
- **Producer** = **per-category fetch lanes** (Pass 99-fu10/fu11). One
  worker group per category, sized to each upstream's concurrency cap:
  `movie`=4, `show`=2, `anime`=1, `music`=1. Each worker calls
  `_process_one(pitem)`; per-service caps live in `media_enricher.py`
  (§5.6). WHY lanes: a single mixed 8-worker pool let the **66%-music**
  library pile ~6/8 workers onto the MusicBrainz `Semaphore(1)` (MB enforces
  ~1 req/sec) while TMDB (movie/show, 16 concurrent) sat idle → throughput
  collapsed to ~4/min. WHY *gentle* sizes (fu11): the pre-fu11 `movie=8 /
  show=4` overran TMDB's real ~5 req/sec → 429s → the 5-consecutive-transient
  guard aborted the movie+show categories (only music survived). With
  OMDb-primary (fu11), movie/show fetch goes through OMDb (supporter key,
  100 k/day, semaphore 10) so TMDB only **supplements** (and a TMDB 429 no
  longer kills the run). `anime` stays at 1 worker because AniList's
  `Lock(1)` + rate limit would otherwise hit the same abort.
  `_process_one`'s per-category abort/rolling/streak state is
  order-independent, so the lanes need no change there.
- **Per-category output queues + round-robin consumer** (Pass 99-fu12).
  Each lane writes into its OWN `asyncio.Queue(maxsize=_PREFETCH_DEPTH)`
  (`cat_queues[cat]`), and a `lanes_done: set` tracks lane completion in
  place of a sentinel. The consumer is a single LLM coroutine but rotates
  fairly: each pass walks `active_cats`, pulls up to `_CONSUME_BATCH = 10`
  items per category (skipping empty queues), processes them serially, then
  rotates. It exits when all lanes are in `lanes_done` AND every queue is
  empty; a short `asyncio.sleep(0.25)` covers the idle case while lanes are
  still feeding. WHY round-robin: the pre-fu12 single FIFO queue meant the
  music lane (instant raw-cache hits → puts a music item every few ms) filled
  the queue before movie/show/anime could land an item. The consumer drained
  in arrival order, so it saw a 10-30 minute pure-music run while the fast
  lanes' producers eventually blocked on `queue.put` (back-pressure from a
  full queue) and stopped fetching → all four categories starved at once.
  Per-category queues let each lane's back-pressure stay local; round-robin
  guarantees movie/show/anime get LLM time the moment they have anything
  ready. Observed effect: cat mix moved from "music-only" to roughly
  proportional within ten minutes of restart (`anime 26 / movie 17 / show 18
  / music 15` per 10-min window at ~7.6/min).
- **Consumer is the throughput ceiling, measured at 14.2 items/min warm**
  (parallel-bench, 2026-05 — 100 varied raw-cache payloads, single coroutine
  through `summarize_with_small_llm`). Mean per-item: 4.24 s = granite ~3.2 s
  + embedding + db/chroma writes. Prod runs at ~7-13/min sustained — the
  ~1-7/min gap is the consumer occasionally idling while the producer waits
  on a slow API. **Don't try to parallelise** it: the same benchmark proved
  the workload is compute-bound on a single GPU (N=2 = ×1.02 throughput
  but ×1.94 mean latency; N=3 = ×1.15 throughput but ×2.55 mean latency).
  See §15 "Don't parallelise the summariser consumer". **Not** a VRAM
  problem — granite (~7 GB) + nomic (~0.6 GB) leave ~16 GB free.
- **Two hard throughput floors** for a full re-enrich: the single consumer
  (~14/min warm ⇒ ~30-50 h for ~28 k items) and the MusicBrainz 1-req/sec
  cap (~17 k uncached music artists ⇒ ~12-24 h, irreducible). Lanes +
  round-robin make music non-blocking, not faster. The only way to push
  past the ~14/min consumer floor is a *faster model* (granite4.1:3b is
  measured 100% JSON-OK + ~1.5× faster but drier prose — Phase-2 candidate
  as a fast-tier summariser).

### 5.3 `fetch_and_prepare_raw` — the two-tier cache + tri-state return

This function (in `media_enricher.py`) is the heart of the cache logic. It
returns one of THREE shapes the producer must distinguish:

1. `{"_already_enriched": True, "_cached_profile": …}` — the polished cache
   (`enriched:{cat}:{id_key}`) holds an LLM profile tagged with the current
   `_PROMPT_VERSION`, OR a fresh (<3 day) not_found sentinel. Producer
   reconciles the EnrichmentStatus row to match, does NOT re-fetch or
   re-LLM, does NOT write a not_found sentinel.
2. A normal raw dict — fresh API data (or a tier-2 `raw:{cat}:{id_key}`
   cache hit). Producer queues it for the consumer's LLM polish.
3. `None` — no API data exists. Producer writes a `not_found` sentinel.

**Why three shapes** (Pass 99-fu / fu2): the pre-fu version returned `None`
for both "already done" and "no data". After a bulk EnrichmentStatus reset,
items whose polished cache was still valid returned `None` → producer wrote
not_found sentinels over perfectly-good items (200 poisoned in 3 s). The
explicit `_already_enriched` marker fixed that.

**Streaming-merge: parallel per-source fetch with provisional-then-full
upgrade** (Phase 2 #39, the current default). Pre-#39, the fetch was a
serial waterfall: OMDb-primary first, THEN TMDB supplement, THEN merge —
each item waited for its slowest source before the LLM polish could
fire. With music's MusicBrainz at 1 req/sec, that meant a music item
waited >1 sec per fetch even when Last.fm had data in 200 ms.

The new model (`_streaming_fetch_runner` in `media_enricher.py`):
  1. `_expected_sources_for(media_type, is_anime, ids)` returns the list
     of APIs to fire IN PARALLEL — `{mb, lastfm}` for music, `{tmdb,
     omdb}` for movie/show with `imdb_id`, `{anilist, jikan}` for anime,
     etc. Each source's existing semaphore (TMDB_SEM=16, OMDb_SEM=10,
     MB_SEM=1, Jikan_SEM=2, AniList_LOCK) still enforces its own rate
     limit inside the parallel fan-out.
  2. `asyncio.create_task` for each source + `asyncio.wait(FIRST_COMPLETED)`
     loop. Each completed task merges into a shared raw via
     `_merge_source_into_raw` (delegates to the existing
     `_merge_raw_metadata` for de-dup + alt_plot_sources population)
     and stamps the source's status into `raw["sources_state"]`.
  3. The MOMENT `_has_enough_data_for_polish(raw, media_type)` flips
     true (title + overview/bio present), the runner deep-copies raw
     as the initial snapshot + returns it with `_remaining_tasks`
     attached. The producer queues the snapshot for LLM polish.
  4. Remaining slow-source tasks keep running in the background; the
     consumer's `_consume_entry` spawns `_finalize_streaming_merge`
     AFTER its polish-write commits (see §5.3.1 — the consumer-first
     ordering is what makes the 🌗 provisional bucket visible). The
     finalizer awaits each remaining task, merges each result into the
     same `live_raw_ref` the runner was mutating, and on completion
     commits `sources_state` (full JSON) + `fetch_tier='full'` +
     `provisional=False` as a SECOND DB update. The raw cache blob is
     also rewritten with the merged full-tier data for future tier-2
     hits.

The OMDb-vs-TMDB primary distinction (Pass 99-fu11) is GONE: both fire
in parallel, whichever returns first is "primary" by happenstance, the
other's data is merged in as soon as it lands. The pre-#39 try/except
TMDBTransientError guard is no longer needed because a transient TMDB
fail just stamps `sources_state["tmdb"]={status:transient}` and the
item continues with the OMDb data — no special-case fall-through.

Sonarr currently does not surface `imdb_id` in collected items, so for
shows the expected_sources list collapses to `{tmdb}` and the OMDb task
isn't fired. Closing that gap is a known follow-up.

### 5.3.1 The consumer-first finalizer ordering

The streaming finalizer (`_finalize_streaming_merge`) is spawned by the
CONSUMER (`_consume_entry` in enrichment.py) AFTER the consumer's
`_write_enrichment_db` call commits — NOT by the producer's
`_process_one`. This ordering matters:

  - Initial design (pre-step-3): producer spawned the finalizer right
    after queueing the item. Finalizer awaited slow sources (~1-2 s)
    while the consumer's deep LLM queue (~3-5 s per item × deep) let
    the finalizer ALWAYS commit first. By the time the consumer
    pulled the item, the row already had `fetch_tier='full'` +
    `provisional=False`. The UI's 🌗 "Enriched (provisional)" bucket
    therefore NEVER lit up — items skipped straight from
    queued-for-retry to LLM-polished.
  - Current design (step 3): consumer pops the finalizer transport
    fields (`_remaining_tasks`, `_live_raw_ref`, `_finalize_*`) off
    raw BEFORE `process_and_save` (so they don't end up in the LLM
    prompt), polishes, writes the initial snapshot (`fetch_tier='fast'`,
    `provisional=True`) to DB, THEN spawns the finalizer. The
    finalizer's later UPDATE flips the row to full/non-provisional.
    Net effect: the row transitions enriched=0 →
    enriched=1+fast+provisional=True → enriched=1+full+provisional=False
    in three observable DB states. The UI bucket actually lights up.

The DB column writes don't conflict: the consumer writes
`enriched/enriched_at/error/vector_ready` (its own columns) plus
`fetch_tier/sources_state/provisional` (initial state). The finalizer
writes ONLY the latter three. SQLite serializes both transactions; the
finalizer's later write is the final authoritative state.

### 5.3.2 When the provisional bucket actually fills

The streaming flow only fires for items that hit NONE of the three
cache layers (raw_prefetch, tier-1 polished, tier-2 raw). Items with a
raw cache hit (tier-2) short-circuit straight to `_fetch_tier='full'`
without any background tasks → consumer writes full directly, no
provisional state, no finalizer.

In practice the provisional bucket is most visible for:
  - **Truly new items** (never fetched — no raw cache yet)
  - **`fast_only=True` runs** (Pass 99-fu13 / #38b) — items
    intentionally STAY provisional with slow sources skipped, until
    the source-upgrade scheduler (§13, #41) hourly cron picks them
    up + re-runs with `fast_only=False` to fill in the missing data
  - **TTL-refresh runs** after the 90 d raw cache TTL elapses

For warm libraries the provisional state is sub-second visible during
streaming-merge transitions (consumer write → finalizer update), so
the bucket counter mostly oscillates near 0 during normal background
enrichment.

**Fast vs Full tier** (Phase 2 #38b — see §5.3 for how it
integrates with the streaming runner). `fetch_and_prepare_raw` accepts
`fast_only: bool = False`. In **fast** mode the streaming runner's
expected-sources list is filtered to drop slow sources: music drops
`mb`, anime drops `jikan`, movie/show drop the secondary supplement.
Those sources never fire, never land in `_remaining_tasks`. Each item
lands `fetch_tier="fast"` + `provisional=True` on the EnrichmentStatus
row and the **source-upgrade scheduler (#41)** scans for
`provisional=True` later and runs a full re-fetch in the background
to fill in the missing sources. The DB column `sources_state` records
every source consulted with status `ok` / `miss` / `transient` /
`skipped` and a timestamp, so the scheduler can target exactly the
sources still pending. WHY: the parallel-benchmark (§15) proved the
LLM consumer ceiling is ~14/min — the throughput-bound is
producer-side waiting on slow APIs. Skipping MB on bulk-pass slashes
the 16 k-music lane from
~4 h to ~1 h, getting every item to provisional "enriched temporary"
state quickly so the user gets coverage; full quality follows in
background. Two transport fields ride with raw across the pipeline:
`_fetch_tier` (`"fast"` / `"full"`) and `_provisional` (`True` only
for fast rows). The raw cache stamps `_tier_at_fetch` into the cached
blob so a future `fast_only=False` request seeing a fast-cached blob
bypasses the cache and re-fetches (otherwise the upgrade pass would
just re-read the same stale fast data). The polished cache (tier 1)
applies the same tier-mismatch check on read.

### 5.4 Two-tier cache + prompt versioning (Pass 99-fu2)

- `raw:{cat}:{id_key}` — pure API fetch result (TMDB/AniList/MB/Last.fm
  merged). 90-day TTL. **Survives prompt changes.** Written by
  `_write_raw_cache()` after every fresh fetch.
- `enriched:{cat}:{id_key}` — LLM-polished profile, tagged with
  `profile["prompt_version"] = _PROMPT_VERSION`. 90-day TTL.

**`_PROMPT_VERSION` (in media_enricher.py)**: bump this whenever the curator
or summariser system prompts change — OR the summariser model changes — in a
way that should invalidate cached profiles. Next run treats version-mismatched
polished entries as "needs re-polish", falls through to the tier-2 raw cache
(no API re-fetch), and re-runs only the LLM. A bump costs LLM-compute, not API
quota. Currently `v2` (bumped from `v1` on the gpt-oss:20b → granite4.1:8b
summariser swap, Pass 99-fu9).

**A bump alone does NOT re-polish already-LLM-done items.** The `enrichment.py`
Step-5 pre-filter skips rows with `EnrichmentStatus.enriched=True AND error IS
NULL` *before* the producer ever consults the cache version. So a bump only
re-polishes what the producer actually sees (never-enriched + rule-based/failed
rows). To re-polish the WHOLE library after a bump, run with `force=True` (the
"🔄 Force Re-Enrich" button) — it bypasses the pre-filter; the raw cache still
spares the API calls. **Two independent skip layers must BOTH yield**: cache
`prompt_version` (producer side) + `EnrichmentStatus.enriched` (pre-filter side).

`id_key` = `anilist_id or anidb_id or tmdb_id or tvdb_id or title[:40]`.
**Gotcha (historical):** `_write_enrichment_db` also writes
`enriched:{cat}:{plex_rating_key}` — a DIFFERENT key namespace that the read
path doesn't consult. Those writes are effectively dead; the canonical
read/write key is the id_key form. Don't rely on the plex_rating_key cache
entries.

### 5.5 Transient errors + abort thresholds (Pass 99)

- **`TMDBTransientError`** (`media_enricher.py`): `_tmdb_get` raises this on
  429 / 5xx / network errors (NOT on 4xx-other-than-429, which is a real
  "not found"). The pre-99 silent `return {}` on any non-200 was
  indistinguishable from a real miss and poisoned ~5,800 movies during a
  TMDB blip.
- **The `TRANSIENT` marker** (`enrichment_state.TRANSIENT`, 2026-09): the
  streaming runner swallowed that exception into a `"miss"`, so the
  producer's transient handler was dead code. Every fetcher now returns the
  falsy, identity-checked `TRANSIENT` for 429 / 5xx / exceptions (TMDB via
  `_fetch_source`, OMDb `None`, AniList, Jikan, MusicBrainz — which no
  longer negative-caches an outage for 7 days — and Last.fm); legacy callers
  that test truthiness see what they always saw. `_merge_source_into_raw`
  stamps `"transient"`; `_drained_outcome()` decides after a drained run.
- **`TransientFetchError`** (subclass): `fetch_and_prepare_raw` raises it
  when nothing sufficient came back and at least one source was unavailable
  while none answered — the producer sleeps `retry_after_s` and SKIPS the
  item; nothing is written. All sources genuinely missed → the
  `{"_not_found": True, "_reason", "_sources_state", "_context"}` dict, which
  `_process_one` checks BEFORE the LLM queue (a not-found dict must never be
  polished into a confident fake profile).
- **50%-not_found abort**: a per-category rolling window (last 50 items); if
  >50% are not_found sentinels, the producer aborts that category — almost
  certainly an upstream API outage, not a real findability problem.
- **5-consecutive-transient abort**: same idea for durable API-down.
- Aborted categories leave their items at `enriched=False` for the next run,
  and the attempts this run counted for them are rolled back
  (`_rollback_attempts`) so an incident cannot eat the retry budget.

### 5.6 Per-service concurrency caps (Pass 99-fu3)

Module-level in `media_enricher.py`, lazy-init via
`_ensure_concurrency_primitives()`:

- `_SEM_TMDB = 16`, `_SEM_OMDB = 10` (raised from 4 in Pass 99-fu11 when
  OMDb became primary for movie/show under a supporter key — 100 k req/day
  ≫ free-tier 1 k), `_SEM_JIKAN = 2`, `_LOCK_ANILIST` (Lock, serialises
  through the existing `_anilist_wait` throttle).
- MusicBrainz already has its own `_MB_SEM = Semaphore(1)` in
  `music_metadata.py` (1 req/s strict).
- These keep the lane workers (§5.2 — movie 4, show 2, anime 1, music 1)
  within each API's rate limit.

---

## 6. The music pipeline (`src/services/music_matcher.py`)

Sequenced phases, all idempotent + resumable. Run daily by the scheduler OR
via the standalone scripts.

| Phase | What | Writes |
|---|---|---|
| **1 — Plex Match** | Spotify plays whose track exists in local Plex get `plex_item_id` rewritten from `spotify:track:…` to the Plex key | `watch_history.plex_item_id` |
| **1.4 — MBID Resolve** | Distinct unmatched Spotify artists get a MusicBrainz MBID | `watch_history.artist_mbid` |
| **1.5 — Spotify Genres** | Genres for unmatched plays via Spotify Client-Credentials | `watch_history.genres` |
| **2 — Last.fm Fallback** | Genres for anything Spotify couldn't resolve | `watch_history.genres` |

- **Standalone runners**: `scripts/music_enricher.py` (full music enrichment,
  bypasses the daily batch cap) and `scripts/mbid_speedrunner.py` (Phase 1.4
  only). Both LLM-free by default — they write the `api_cached — LLM pending`
  marker so the in-app enrichment consumer does the LLM polish later.
- **Coordination**: both set `music_pipeline_running="1"` in AppState while
  active (`music_enricher.py` and the in-app pipeline take it as a lease,
  `src/services/state_lease.py`: the holder's host/PID/heartbeat sit in
  `music_pipeline_running_holder`, so a server restart leaves a live
  enricher's flag alone; `mbid_speedrunner.py` still sets the bare flag). As of Pass 97 they do NOT also block on `enrichment_running` —
  the cross-mutex was removed (chunked commits + disjoint DB rows make the
  two safe to run concurrently). They self-check `music_pipeline_running`
  only (two music runners would collide).
- **Chunked commits** (Pass 89): `enrich_music_genres_spotify` commits every
  100 tracks (`_DB_COMMIT_CHUNK`) so a long run doesn't hold the SQLite
  write-lock for minutes and starve other writers.
- **ext-script rows**: Spotify-seeded artists with no Plex section get
  `plex_rating_key` prefix `ext-script:`. They're excluded from per-library
  "% enriched" counts (Pass 91b) but still feed the music taste vector.
- **Spotify dev-mode quota** (Pass 99-fu8): a Spotify app in Development
  Mode (the default) has a tiny quota; a bulk Phase-1.5 run blows it and
  Spotify returns a flat 24 h ban (`retry-after: 86400` on the data
  endpoints, token endpoint still 200). `spotify_client.py` persists a
  long retry-after to AppState (`spotify_backoff_until`, wall-clock,
  survives restarts) and skips Spotify entirely until it elapses — re-
  knocking during the ban can reset the 24 h window, so silence is what
  lets it expire. Last.fm (no such quota) carries Phase-1.5/2 genre work
  meanwhile. Short transient 429s still use an in-process monotonic
  backoff. To actually fix the quota: apply for Spotify Extended Quota
  Mode (slow, uncertain since the 2025 policy change) — or just rely on
  Last.fm, which is plenty for taste-vector genres.

---

**Lyrics (2026-09-14, `src/services/lyrics.py`).** Two custodian walkers.
`lyrics_sync` (24 h, no GPU) lists every track of the Plex music sections —
the ones mapped in Libraries, else every artist-type section Plex has — with
`includeElements=Stream` (one walk = 47 page calls for 18,600 tracks), keeps
identity and lyric stream per track in `data/cache/lyrics.db`, fetches the
text of every new or changed stream within a budget of 4,000 per run (LRC
timestamps and header tags stripped), drops text whose sidecar vanished,
marks tracks gone only after a COMPLETE listing, and leaves a stream Plex
refuses (404: SoulSync moved the file since the last scan, 6 % live) alone
for a week. `lyrics_profile` (24 h, `needs_llm`) takes artists with eight
texts on file or half their tracks (three at least), shows the summariser an
album round-robin sample (listened tracks first, first lines of each,
14,000 chars), keeps only verbatim quotes of at most twelve words, stores the
profile, attaches it to the artist's raw cache entries as `lyrics` +
`lyrics_v` (re-attached every run when a raw refresh dropped it), expires
the polished summary and re-polishes at once. The summariser's music prompt
reads the `LYRICS PROFILE` block and may make lyrical claims only from it;
the verified block carries `Lyrics (n of m tracks on file)`; the album
dossier adds coverage and one line; the Music pipeline tab shows the
coverage; the explicit flag is collected, not yet acted on.

**Lidarr optional (2026-09-15).** `music_source.music_service()` answers
`lidarr` when Lidarr is configured (unchanged behaviour: structure index and
delete write-path stay Lidarr's), `plex` when the Plex music index built by
the daily walk (`plex_artists`: name, mbid from the `mbid://` guid — 95 % of
the owner's artists —, albums, tracks, footprint of every version's part)
has artists, `None` otherwise. On `plex`: the deletion candidates for music
are the index's artists in the Lidarr shape (service `plex`, media_id = the
Plex artist key = the enrichment key), a `plex` proposal is deleted through
`DELETE /library/metadata/<key>` (files included — Plex 'Allow media
deletion'), re-read, a 200 counts as failure, the artist leaves the index
at once; the Music page runs on the index (rows in Lidarr's shape, a
"Plex index" badge, tabs All Artists / Spotify Backlog / Wanted, Re-enrich
by name + mbid), adds become wishes (`music_wishes`; Recommendations and the
backlog write them, the Wanted tab lists them, `in_library` turns green once
Plex has the artist), the curator's discography line and album dossier come
from the index, the Knowledge Base music row and the Discogs artist universe
read it. The Libraries mapping still has no music section on the owner's
instance; the index does not need one.

**Series editions (2026-09-15, `src/services/editions.py`).** Measured
first: the prose the enrichment collects mentions censorship for 13 of
2,443 anime and half of those are plot, while the AniDB tags of the offline
snapshot carry it as a fact — read by AniDB's own definitions, after the
first walk had built on the wrong tag: "tv censoring" (147 of the owner's
series) says the TV version is censored and the DVD/BD releases are
uncensored, so the disc is the uncensored cut; "censored uncensored
version" (32) says even the disc release keeps censoring (no nipples in
KissXSis, the steam stays in Kanamemo, sometimes more than the TV cut);
"uncensored version available" is rare; the other censoring tags say the
airing was cut. Ownership sits in Sonarr: every episode file carries its
parsed quality source (bluray / dvd against web / television), its release
name and the custom formats the owner defined; web rips say "uncensored"
(AT-X, HIDIVE), disc rips rarely bother. `editions_sync` (custodian,
weekly, no LLM, one episodefile call per series at 60 requests a minute,
cursor + budget, rows without a source count re-walked) keeps
`series_editions`: files from a disc source, files named uncensored / uncut
/ unrated or censored, the custom format names, a sample release, the AniDB
flags and tags for anime. Readers: the verified block's "Edition:" line for
shows and anime ("24 episode files, all from Blu-ray/DVD, none named
uncensored; AniDB: the TV airing was censored, the Blu-ray/DVD release is
the uncensored cut"), the Curation upgrade list ("TV cut — uncensored disc
release exists" when broadcast or web files sit on disk, none named
uncensored, and AniDB says the disc is uncensored; largest TV-cut share
first) with a Search-releases button that asks Sonarr's indexers live for
seasons 1–2 (`/api/v3/release`, never a sweep; offers uncensored by name or
from a Blu-ray/DVD source). Radarr editions (unrated cuts) are not covered.

---

## 7. Taste engine (`src/services/taste_engine.py`)

Reads watch history + enriched profiles + episodic memories + explicit
feedback. Produces a per-user, per-category embedding centroid (stored in
`encrypted_taste_vectors`) plus a written summary line injected into every
curator prompt. **Memory carry-over**: on recompute, prior aversions
(genre/theme/mood) and disliked titles are merged with the strongest value
preserved — recomputing doesn't wipe learned signal. Recompute triggers on
demand or after `recs_invalidate_at` bumps (any Plex sync that wrote rows,
any enrichment that finished items).

`taste_vectors.py` is the legacy module; `taste_engine.py` is the active one.

---

## 8. Recommendations + deletion proposals (`src/services/recommendations_engine.py`)

- **Recommendations**: vector-similarity search over ChromaDB scoped to the
  category (the `domain` metadata filter prevents cross-category bleed —
  show recs never surface anime, etc.). Each rec gets a written curator
  pitch. Cached in `cached_recommendations`.
- **Deletion proposals** (`generate_deletion_proposals`): two phases.
  - **Phase A — scoring**: per item, compute a deletion score from vector
    mismatch (dominant), file size (log-scaled), external rating swing, and
    (music only) the user's own Plex star rating. Hard-skips: protected
    media, titles the curator positioned on in chat (last 60 d), recently
    watched (last 90 d), kids/family content, ≥4-star user rating (music).
  - **Phase B — LLM pitches**: top 10 candidates per category get a written
    deletion pitch from the curator model. This is the expensive part
    (~40 LLM calls per full regen) and is what makes "ARR Sync" contend
    with the enrichment summariser for the LLM.
  - `monitor_task` kwarg (Pass 99-fu5) lets the scheduler thread the
    task_monitor through for per-phase / per-pitch progress messages.
- **numpy gotcha** (Pass 74): item embeddings are numpy arrays — guard with
  `is not None`, never truthiness.

---

## 9. Chat + curator (`src/routers/chat.py`, `src/services/episodic_memory.py`)

- **Thread isolation**: `conversation_messages.thread_id` keeps free chat,
  per-proposal discussions, and per-message discussions separate.
- **discuss_context** (Pass 90a): when the frontend sends a proposal_id +
  title, the server re-fetches the proposal from the DB and verifies the
  title matches before injecting context — defends against stale frontend
  references resolving to the wrong title after ID reuse.
- **Single-entity extraction**: each chat message resolves ONE primary title
  anchor (regex + LLM extraction, biased toward titles with user-state in
  the DB). The cascade then fetches metadata for that one title. This is why
  pasting a list of 100 titles gets one answered, not all 100.
- **Language detection** (`llm_utils.detect_user_language`, Pass 99-fu6):
  density-based — trips `de` only on 2+ umlauts, OR 1 umlaut + 2 tokens, OR
  5+ distinct tokens at ≥1 token/1000 chars. Replaces the old absolute
  threshold that false-positived on a few German cognates in long English
  text. The detected language is injected into curator/summariser/pitch
  prompts so output matches the user's chat language.
- **Protection-intent detection**: German + English keyword vocabularies in
  `episodic_memory.py` + `llm_utils.py` are INTENTIONAL — they recognise a
  German-speaking user's "behalten / nicht löschen" directives. Do not
  "translate" those to English.
- **German keyboard typo handling** (chat.py): `ß → 0` near digits (a fast
  typist hits ß instead of 0 in years like "202ß" → "2020"). Intentional.

---

## 10. Proactive messages (`src/services/proactive_messages.py`)

The curator polls for triggers (new season of a binged anime, idle-user
picks, long-break check-ins), writes `proactive_messages` rows, the frontend
shows a badge. Per-trigger toggles in Settings → Notifications.
**Gotcha**: `created_at` is forward-dated (`now + timedelta(seconds=…)`) to
force ordering — filters using `created_at <= now` would skip them until
wall-clock catches up.

**Sittings, not windows** (`src/services/viewing_sessions.py`, 2026-09-22).
The plays of one series are chained into sittings by their own timestamps
and lengths (next play within the previous one's length + 45 min); a play
counts as an episode only when at least half watched AND at least half an
episode after the previous play — bulk "mark as watched" rows and
re-imports after a file upgrade (12 episodes of Kakegurui inside one
second) are plays, not viewing. `rhythm()` writes the result down ("6
episodes in one sitting this evening (2 h 13 min)" / "11 episodes over 3
sittings in the last 7 days, at most 6 in one go") and the prompts repeat
it verbatim: `binge_episode` fires only for a sitting of
BINGE_EPISODE_THRESHOLD episodes that ended within BINGE_SESSION_HOURS,
`series_completion` counts viewing, and the chat starters' `active_series`
fact carries the rhythm with an explicit `binge` flag (the fact used to be
called `current_binge` for any series with three plays in a week, and the
model obliged).

**Subject memory and the pick.** `_load_asked_subjects` indexes every
pattern type's subject (`"new_genre:latin"`, a night-owl title, each
dropped show, an artist) with the date it was last the message, and the
detectors skip a subject inside its horizon (30 days for genres, titles and
drops; 14 for attention lapses; 7 for a marathon) — before this, `new_genre`
"latin" was the bell's news eight times in three weeks. `_run_all_triggers`
now runs every detector and `_pick_trigger` draws among the hits weighted
by how long each type has been quiet (1 + days, capped at 15); a watched
recommendation still wins outright. Guard: `tests/test_viewing_sessions.py`.

**One curator, several registers; the thread continues; a morning line**
(same day, `tests/test_proactive_alive.py`). Every message draws a register
by weight — provocative 3, curious 3, dry 2, warm 2, analytic 2 — never
the same as the previous message's (read back from its `trigger_data`),
and `_compose_prompt` (sync, testable) ends every prompt with that one
register line; the per-type prompts carry the facts and the ask only, the
old "provocative, teasing, confrontational" wording and the random "No
filter." suffixes are gone. Continuity: `_load_asked_subjects` also maps
every subject key to the ids of earlier messages about it, and
`_prior_exchange` reads their `proactive_message:{id}` threads for the
newest one the user actually answered in — the prompt then quotes what we
asked, what they said and what we replied, and tells the model to build on
it. `last_night`: between 05:00 and 11:00 local, once per day, the plays
between 17:00 yesterday and 04:00 today (series with their sitting rhythm,
films, minutes of an artist) become one morning line; it outranks every
trigger but a watched recommendation and expires at local noon
(`_expiry_for`). Settings → Notifications lists it as "Morning line".

---

## 11. LLM priority system (`src/services/llm_priority.py`)

Curatarr runs two Ollama model roles that compete for VRAM:

- **Curator** (`curatarr-curator`, large — `gemma4:31b` — chat,
  recommendations, deletion pitches)
- **Summariser** (`curatarr-summarizer`, small+fast — `granite4.1:8b` —
  enrichment polish, memory extraction; backup `granite4.1:3b`)

`curator_start()` marks the curator as priority → the summariser is evicted
from VRAM → the enrichment consumer pauses. `curator_done()` releases it.
This is why: (a) chatting with the curator pauses enrichment, AND (b) the
deletion-pitch generation (which uses the curator) ALSO pauses enrichment —
the "Curator active — pausing enrichment" log fires for both.

**Game mode** (`process_monitor.py`): a 30 s scheduler watcher detects known
game processes. When a game is running, both models are evicted and the
enrichment pipeline only does API pre-fetch (`_write_game_mode_db` writes the
`api_cached` marker, no LLM). Resumes automatically on game exit.
`is_game_running()` is the union of two causes that `llm_lane` tells apart:
`game_process_running()` (a game owns the whole box) and `gpu_pressure()`
(something else owns only the card). Called on the event-loop thread, both
answer with the last verdict instead of probing (nvidia-smi, a process
walk); the watcher probes on a worker every 30 s. Jobs read the flag
through `app_state.game_active()`, where an unreadable flag counts as a
game. The "is this a game?" toast asks only
about programs that hold at least `PROMPT_MIN_RSS_MB` (500) of memory, are
no installer, updater or crash handler, and are no version of a program
already answered "not a game" (`process_family` folds version numbers and
hashes: `_is4993.exe` and `_is3b83.exe` are one family); games match
exactly. Steam's overlay counts as a signal in both its 32- and 64-bit form.

**CPU lane** (`llm_lane.py`, 2026-09-15): a GPU held by another program no
longer stops the LLM work — it moves the part that can move. Measured on the
owner's box while an image-generation job held 17.5 of 24.5 GB at 90 %: the
19.9 GB curator never got its model server up (304 s, then "timed out
waiting for llama-server to start" — the same failure as the summariser
timeouts the evening before), while the 5.3 GB summariser ran the app's own
SUMMARIZE_PROMPT filled from a real cache entry (2,542 tokens in, 440 out) in
145 s on six CPU threads without the GPU moving a megabyte. Six threads are
as fast as twelve (17.4 against 17.6 tok/s on a 3B — generation is
memory-bandwidth bound), so half the CPU stays with whatever else is
running. Cores are not the only cost: the same run took 9.7 GB of system RAM
(weights, KV cache and mapped file pages, not the 5.3 GB of the file), and
the program on the card wanted memory too — the owner's image job sat at
29 of 64 GB, leaving 7.8 GB at the trough. So the lane reads free memory
before it promises anything and stays closed below `LLM_CPU_MIN_FREE_MB`
(12 GB) rather than push the machine into swap; a host that cannot be
measured keeps its background work. `lane(role)` answers gpu / cpu / none;
`ollama_options` carries the
placement into every summariser call site, `curator_options` pins
`num_gpu=99` because a 19.9 GB model on the CPU is not an answer, it is a
wait. The custodian reads `Task.llm_role`: the seven summariser-class tasks run
on the lane, the curator-class ones stay deferred. `POST /api/chat/message`
checks first and returns the notice as a curator reply before the in-flight
guard and the priority gate are even taken. The 30 s game watcher records the state every tick like it
records `game_active`: `llm_lane` in app_state (free / game / cpu /
paused) plus `llm_lane_reason`, logged only on a change, served by
`GET /api/processes/status`, and rendered by the topbar badge — green
"Game mode" as before, amber "GPU busy · CPU lane" or "GPU busy · paused"
with the occupancy in the tooltip. `LLM_CPU_LANE=0` restores the old
stop-everything behaviour; the wizard's Ollama step asks for it and
Settings → Integrations → "Sharing the graphics card" changes it later
(together with `GPU_PRESSURE_GATE` and the thread budget, applied live).

`embedding` model (nomic-embed-text, ~0.5 GB) is intentionally NOT managed by
the priority system — too small to matter against the 20-27 GB model dance.

---

## 12. Cache namespaces & TTLs (`MetadataCache`)

All keys auto-prefixed with `_CACHE_VERSION` (`"v2"`).

| Key | Written by | TTL | Purpose |
|---|---|---|---|
| `raw:{cat}:{id_key}` | `_write_raw_cache` after fresh fetch | 90 d | tier-2 raw API data (prompt-bump-safe) |
| `enriched:{cat}:{id_key}` | `process_and_save` / `enrich_media_item` | 90 d (LLM) / 3 d (not_found) / 1 d (rule-based) | tier-1 LLM-polished profile, tagged `prompt_version` |
| `enriched:{cat}:{plex_rating_key}` | `_write_enrichment_db` | same | **dead** — nobody reads this key form |
| `raw_prefetch:{plex_rating_key}` | game-mode consumer | 7 d | API data persisted during game-mode for next-run reuse |
| `arr_library:{svc}` | library.py L2 persist | 30 d | full ARR library blob (L1 in-memory + L2 disk) |

**ARR library cache** (`library.py`): two-tier. L1 = in-process dict (15 min
freshness). L2 = `arr_library:{svc}` MetadataCache rows (30 d). Live fetch
populates both. On a fetch failure, serves stale L2 with a "⚠ cached" badge.
Prewarmed at startup.

---

## 13. Scheduler jobs (`src/services/scheduler.py`)

APScheduler. Defaults:

| Job | Cadence | Notes |
|---|---|---|
| Plex sync | every `SYNC_INTERVAL_HOURS` (24 h) | + startup catch-up if overdue |
| ARR sync (deletion proposals regen) | daily | + startup catch-up. 40 LLM pitches. |
| ARR pre-enrichment | 02:30 daily | **batch-capped at `ARR_PRE_ENRICH_BATCH`=80** — a trickle, NOT a backlog-drainer. To clear a big backlog you must run the manual `/api/enrichment/start` (no cap). |
| Music match + Last.fm | daily | the in-app music pipeline |
| Enrichment TTL refresh | daily | re-queues stale profiles, prefers un-enriched first, preserves `enriched_at` (Pass 86) |
| Source-upgrade pass | hourly (:15) | Phase 2 #41 — promotes 30 oldest `provisional=True, fetch_tier='fast'` rows by re-enriching with `fast_only=False`. Bypasses the watch_history + ARR collect (uses `specific_plex_rating_keys` to target rows directly + `force=True` to defeat the LLM-done pre-filter). No-ops on game-mode or main-enrichment contention. Ceiling is the MB 1-req/s for music — 30/hour matches that pacing. ~22 days to drain ~16 k provisional music items. |
| Proactive cache fill | 30 min | |
| ARR library cache refresh | 30 min | |
| Memory decay | weekly | |
| Orphaned-section check | weekly | |
| DB vacuum | weekly | postponed (stays due) while `enrichment_running`, `music_pipeline_running`, `plex_sync_running` or `deletion_run` is set; a file with < 20 % free pages is not rewritten |
| Game-mode VRAM watcher | 30 s | |
| Model update check | daily | + 5 min after a start when overdue; registry vs running weights, network only (§0 Model updates) |
| Model update recovery | once, 2 min after start | rolls back a model-update test a restart cut off |

**Startup catch-up gotcha**: a fresh server start fires the overdue ARR-sync
(deletion pitches, ~40 LLM calls) AND the ARR pre-enrich (80 items)
simultaneously. Both use the curator/summariser, so the first ~10 min after
a restart has LLM contention. This is the single biggest "why is enrichment
slow right after restart" cause.

---

## 14. Standalone scripts

| Script | Purpose |
|---|---|
| `import_spotify.py` | Bulk-import Spotify `Streaming_History_Audio_*.json` into watch_history (batch 5000) |
| `run_pipeline_spotify.py` | Manual trigger for the music pipeline |
| `scripts/music_enricher.py` | Standalone music enrichment, bypasses daily cap, LLM-free |
| `scripts/mbid_speedrunner.py` | Standalone Phase 1.4 MBID resolve |
| `build_models.py` | Bake `curatarr-curator` + `curatarr-summarizer` Ollama tags |
| `update_db.py` | Idempotent schema migration (`Base.metadata.create_all`) |
| `benchmark.py` | Ollama model throughput benchmark |
| `scripts/dev/` | ~40 one-shot debug/fix/repair tools, **gitignored** |

---

## 15. Tribal knowledge — invariants that bit us

Things that are non-obvious and have caused real bugs. Don't undo these
without understanding why they exist.

- **Enrichment resolves the WRONG same-named work without a disambiguator**
  (Fix A, `4789046`+). The pipeline searches by title STRING and takes the first
  hit — *Lupin III* resolved to the 2012 *Fujiko Mine* spin-off, *Blown Away
  1994* to the 1992 film, the hardstyle *Solstice* to a doom-metal band. The
  curator then reasons (impeccably) about the WRONG entity. Feed the arr's own
  disambiguators end to end: `year` (Radarr/Sonarr) + `mbid`=`foreignArtistId`
  (Lidarr). `search_anilist_by_title(year=)` / `_tmdb_search_and_fetch(year=)`
  prefer the year match; `fetch_musicbrainz_artist(mbid=)` pins the exact artist.
  A **>5y year delta-check** in `fetch_and_prepare_raw` rejects a wrong match
  (enrich nothing > enrich garbage). "🔍 Audit metadata" re-detects & requeues
  existing wrong-entity rows (`_entity_divergence_reason`).
- **Verified data is fragmented across cache keys; the READ must MERGE**
  (`3bf84ad`). The enriched profile (themes) sits under the doc-id key, but OMDb
  fields + Wikipedia significance live on the ID-keyed raw entry
  (`raw:anime:239214`). A doc-id-only lookup misses them → a deletion DISCUSSION
  got a thin profile. `build_verified_data` reads the enriched profile's
  *embedded* ids and merges the raw entries field-by-field.
- **Anime/show verified data is keyed by anilist_id / arr-doc-id, not tmdb_id**
  (`321ef8b`). A tmdb-only lookup silently misses ~5.3k cached anime profiles →
  the curator cold-reads / inverts the plot (Skate-Leading narrated as *Yuri on
  Ice*). Pass every id the candidate carries, incl. `plex_rating_key`
  (`"sonarr:3176"`).
- **`ensure_verified_data` (async, fetches on demand) vs `build_verified_data`
  (cache-only)**. The OMDb/Wikipedia top-up only fires via `ensure_*`. Every path
  where the curator REASONS about a title — delete, discussion, reevaluate/
  Level-2, recommend, general chat — must use `ensure_*`, else it pitches from a
  stale, significance-less profile.
- **Significance is SUMMARISED from a fetched source, never recalled, and never
  inflated** (`2221afb`, `daa3334`). The 27B confidently invents creators/legacy
  for niche titles, so `fetch_significance` distils the Wikipedia extract (UA
  must include a URL — generic UAs get 403). A new title with only production
  facts (cast/location/funding/premiere/debut) → output NONE; don't dress those
  up as "pioneering cultural significance".
- **Title-less memories must NOT match each other by empty title** (`0b6f44c`).
  `resolve_memory_conflicts` matched by `metadata.title`; general preferences
  have none, so `"" == ""` made one save run a NUANCE check against EVERY other
  title-less memory and mass-decay dozens — including the keep/value pillars — on
  every save. Title-less memories now find conflicts by embedding similarity
  (≥0.80): a restatement reinforces its twin, unrelated prefs untouched.
- **Watch status comes from `watch_history` (Plex sync), NOT Tautulli** — the
  owner doesn't run Tautulli. `completed` + `viewed_at` per playback row;
  `chat._watched_lookup` surfaces it so the curator tells an unseen-but-curious
  title from proven dead weight.
- **`deletion_proposals.id` must stay AUTOINCREMENT** (Pass 90c). Without it,
  SQLite reuses ROWIDs after delete; a stale frontend cache holding an old
  proposal_id then resolves to the WRONG title on a follow-up request (the
  Curator "hallucinated about The Thing when discussing The Internship"
  bug). Combined with soft-delete (`status='superseded'`, Pass 90b) so IDs
  are never freed.
- **`fetch_and_prepare_raw` returns three shapes, not two** (§5.3). Returning
  bare `None` for "already done" re-poisons items as not_found.
- **`_tmdb_get` distinguishes transient (429/5xx) from real-miss (4xx)**
  (§5.5). Silent `return {}` on any non-200 poisons thousands during a blip.
- **Cache key asymmetry** (§5.4): read path uses id_key, one writer uses
  plex_rating_key. The plex_rating_key entries are dead. If you "fix" a
  not_findable bug, clear the id_key cache, not just the plex_rating_key one.
- **ARR collect needs ≥60 s timeout** (Pass 99-fu7): the full-library
  endpoints return 15-30 MB; 10 s times out under load and silently drops
  the entire ARR item set.
- **numpy embeddings**: `is not None`, never truthiness (Pass 74).
- **Syncthing `.stignore` is Hidden on Windows** (`fc5b5ce`). `sync_guard`
  excludes `data/` from Syncthing so the live WAL DB isn't hashed (→
  `database is locked`, and worse, a half-synced WAL can corrupt the DB). The
  entry is never removed (`disable()` is a no-op), so a crash leaves the DB
  excluded. Syncthing creates `.stignore` with the **Hidden** attribute, and
  `Path.write_text` (open `'w'` → `CREATE_ALWAYS`) raises `[Errno 13]
  Permission denied` on a hidden/system file. `sync_guard._write_atomic`
  writes a temp file, clears Hidden for the `os.replace` and sets it again;
  where the directory refuses a temp file it falls back to the in-place
  `r+` (`OPEN_EXISTING`) write in `_write_preserving`. A silent failure here
  re-enables the DB-lock storm. With the main DB's 15 s `busy_timeout` and
  writers that commit per call, a lock held past 15 s is usually an
  *external* holder (Syncthing) or the weekly VACUUM, not request traffic.
- **Moving a Sonarr series between root folders needs `/series/editor`, not
  `PUT /series/{id}`** (`891aa8e`). A series has an authoritative `path` field
  separate from `rootFolderPath`; a plain PUT updates the latter but leaves
  `path`, so `moveFiles` relocates nothing (profile/type edits still apply).
  `PUT /api/v3/series/editor` recomputes `path` and performs the move
  (`moveFiles` is a body field there, not a query param).
- **`force_set_state` for flag cleanup** (Pass 89b): the normal `set_state`
  fails in a write-lock cascade — exactly when releasing a flag matters most.
- **Language-detection keyword lists are intentional** (§9): don't translate
  the German vocabularies to English.
- **ARR pre-enrich is batch-capped at 80** (§13): it will never drain a big
  backlog. Use the manual full enrichment run.
- **Two LLM workloads contend** (§11): enrichment summariser vs curator
  (chat + deletion pitches). 0-throughput enrichment usually = curator busy,
  not a bug.
- **Don't parallelise the summariser consumer — it's compute-bound, not
  I/O-bound** (parallel-bench, 2026-05). The intuitive "run 2 or 3 granite
  slots" idea was measured end-to-end (`auto_benchmark_parallel.py`, 100
  varied raw-cache payloads, `OLLAMA_NUM_PARALLEL` = 1 / 2 / 3 with
  matching `asyncio.Semaphore(N)`): throughput barely moved (×1.00 / ×1.02 /
  ×1.15) while **mean latency scaled linearly with N** (4.24 s → 8.21 s →
  10.84 s) — the classic signature of a compute-bound workload on a single
  GPU. The 4090's tensor cores are already saturated by one granite4.1:8b
  inference at `num_ctx=8192`; adding slots just time-slices the same
  cycles N ways. Memory is NOT the limit (granite ~7 GB + KV ~1.3 GB × N
  fits in 24 GB easily); GPU compute is. **Lesson:** before reaching for
  more parallelism, measure whether the unit is compute- or I/O-bound.
  For LLM inference on a single discrete GPU it's almost always compute,
  and the lever is a faster/smaller model or a better-utilised batch,
  never more concurrent clients.
- **`uvicorn --reload` on this setup is lazy** — file changes are
  detected but the process restart is deferred until a browser-side
  signal (tab close / hard refresh) invalidates the ASGI state. As
  long as a tab keeps the connection warm, the OLD process keeps
  running with its original imports + background coroutines, even if
  the .pyc has been re-compiled by an out-of-band Python import. This
  trapped a half-day during Phase-2 #38a deployment: the new
  instrumentation showed up on disk + worked in isolation but every
  newly enriched DB row had NULL `fetch_tier` for hours because the
  long-running producer coroutine had captured pre-edit function
  references at its 09:14 startup and `--reload` never fired.
  **Defensive rule**: when an edit must take effect on a live
  background task, tell the user to explicitly restart (Ctrl-C +
  start.bat), and verify the new code is live by inspecting a FRESH
  DB row written after the restart — don't trust the `.pyc` mtime
  alone, and don't trust the AppState `enrichment_running` flag
  (which can be stale across reload boundaries).
- **The consumer's real warm ceiling is ~14/min, but prod sees less** —
  the gap is producer-side, not consumer-side (parallel-bench, 2026-05).
  The same benchmark established 14.2 items/min as the *warm* steady-state
  with cache-resident raw data, no DB writes, no API fetches in the loop.
  Prod runs at ~7-13/min sustained. The delta is the consumer occasionally
  idling because the per-category queue is empty while the producer is
  waiting on a slow API (MusicBrainz 1 req/s, OMDb/TMDB network RTT,
  Last.fm). The lever to close that gap is **faster producer-side fetch**
  (Phase 2: fast-only fetch mode, Last.fm-primary music with MB upgrade
  in background, provisional state) — NOT more consumer concurrency.
- **Summariser = `granite4.1:8b`, and valid-JSON ≠ accurate** (model bench,
  2026-05). The summariser is the enrichment-throughput bottleneck. gpt-oss:20b
  ran ~12.6 s/item AND occasionally emitted *invalid* JSON (verbose; hit the
  2600-token cap mid-object). granite4.1:8b: ~3.2 s/item, 97% valid JSON over
  36 hard items, factually accurate + rich prose. **CRITICAL warning:**
  `nemotron-3-nano:4b` is fast and reads beautifully but **hallucinates** — it
  invented "Paul sells refugees to Hutu militias for food" for *Hotel Rwanda*
  and movie-only characters (El Drago/Woonan) for *One Piece*, systematically
  skewing toward "edgy/dark" to sound clever. Never choose a summariser on prose
  vibe; fact-check the `embedding_text` against known titles. `granite4.1:3b` is
  the fast 100%-JSON backup (accurate but drier).
- **The summariser's `num_ctx` is pinned in the Modelfile, not the request**
  (model bench). `build_models.py` → `setup_wizard.create_model` bakes
  `PARAMETER num_ctx 8192` into `curatarr-summarizer`; `ollama_options` sends NO
  num_ctx. That is why production loads lean (~7 GB) instead of at the base
  model's native context (granite default = 131072 → ~74 GB → spills to CPU).
  When benchmarking a candidate as a *raw base model*, you MUST pin
  `num_ctx=8192` or the VRAM + first-item timings are meaningless artifacts.
- **`force=True` now actually clears the polished cache** (fixed in
  `4c5886b`). The pre-fix DELETE pattern was `LIKE 'enriched:{cat}:%'`
  but every api_cache key is prefixed with `_CACHE_VERSION` ("v2:") by
  `MetadataCache.set_cache` — the LIKE matched zero rows, so for months
  `force=True` was a no-op against the polished tier-1 cache. Re-polish
  only happened when a `_PROMPT_VERSION` bump invalidated profiles by
  version. The fix imports `_CACHE_VERSION` + prefixes the pattern so
  the DELETE actually nukes the rows it's supposed to. `force=True`
  does NOT clear the raw cache (`v2:raw:{cat}:%`) — that's API fetch
  data we already paid for; clearing it would burn quota for no quality
  gain. Items with raw cache still hit the tier-2 short-circuit on a
  force run; only the LLM re-polishes.
- **A numeric `rating` field will crash `_merge_raw_metadata`** if its type
  isn't guarded (Pass 99-fu11). The Jikan supplement put a content-rating
  STRING into `sup["rating"]` (e.g. `"Rx — Hentai"`), and the merge did
  `if "Rx" in mal_rating:` directly. When `fetch_omdb_data` was extended to
  return the full core profile, it added a NUMERIC `rating` (OMDb's
  imdbRating, a float). Suddenly `"Rx" in <float>` raised `TypeError:
  argument of type 'float' is not iterable` — for *every* movie and show
  going through the merge. The producer caught it in the generic `except
  Exception` and logged a one-line "Producer error", so the symptom looked
  like silent failure: anime/movie/show stuck at the same counts forever
  while only pre-cached music flowed. Fix in place: `isinstance(mal_rating,
  str)` guard before any string membership check. **Lesson:** any time a
  fetcher's return shape grows, audit every consumer that does duck-typed
  string ops on its fields — `"X" in field` is a silent type-incompatibility
  trap.
- **A single FIFO output queue lets the fastest producer starve the others**
  (Pass 99-fu12). Cached items return from `fetch_and_prepare_raw` in single
  digits of milliseconds. With 66 % of the library being music and most
  music items hitting the raw cache after one warm-up pass, the music lane
  filled a single shared `asyncio.Queue` faster than the consumer could drain
  it. Movie/show/anime producers eventually blocked on `queue.put` (full
  queue back-pressure) and stopped fetching entirely → the user saw "only
  music in the activity feed" for 10-30 min stretches even though the
  movie/show/anime lanes were healthy. Fix: per-category output queues
  (`cat_queues[cat]`) + a round-robin consumer pulling `_CONSUME_BATCH = 10`
  per category per pass, plus a `lanes_done: set` for termination in place
  of a single sentinel. **Lesson:** when producers have wildly different
  per-item latencies (cache hits vs network round-trips), a shared FIFO
  hides which side is bottlenecked because the back-pressure couples them.
  Per-category queues + fair scheduling decouples the lanes, and the
  per-category cat-mix in the last 10 min becomes the at-a-glance health
  signal ("fair mix → fine, all-one-cat → starvation").
- **WHO spawns the streaming finalizer determines whether the
  provisional UI bucket is visible** (Phase 2 #39 step 3). The first
  cut had the PRODUCER spawn `_finalize_streaming_merge` right after
  queueing — that ran in parallel with the deep LLM consumer queue,
  and since the finalizer's slow-source fetch (~1-2 s) was much
  shorter than the consumer's queue-traversal time (~30 s per item ×
  N), the finalizer always committed first. The row went directly
  from `enriched=0` to `enriched=1, fetch_tier='full', provisional=
  False`. The 🌗 "Enriched (provisional)" bucket NEVER lit up —
  the user designed for visible provisional → upgraded transitions
  and couldn't see any. Fix: spawn the finalizer in the CONSUMER
  (`_consume_entry`) AFTER the polish-write commits the initial
  `fetch_tier='fast', provisional=True` snapshot. The finalizer's
  later UPDATE then visibly flips it to full. **Lesson:** in a
  two-writer race where one writer is "fast & happens in the
  background" and the other is "slow & in the foreground critical
  path", the SLOW writer (the LLM consumer) must commit FIRST or
  the intermediate state the user designed for is invisible. The
  spawn-point choice is the contract that enforces this ordering.
- **`_write_raw_cache` strips underscored keys** (Phase 2 #38b, fix
  `a2e07a6`). My initial cache-tier marker was `_tier_at_fetch` — but
  `_write_raw_cache` strips every key starting with `_` (that's the
  contract for transport-only fields like `_cache_key`, `_tmdb_id`).
  So the marker was getting wiped on write + the tier-2-hit read path
  always defaulted to "full" regardless of what was actually cached.
  Renamed to `cache_tier` (no underscore) so it survives the strip
  and persists across cache reads. **Lesson:** if a field needs to
  survive disk serialization, don't make it look like a transport
  field. Underscore prefixes are a convention; cache layers enforce
  them.

---

## 16. Environment / config (`src/config.py`)

`.env` (written by setup wizard; `CURATARR_ENV_FILE` moves it, which the
container does to `/app/data/.env`). `.env.example` lists every key with its
default and must load as-is (`tests/test_env_example.py`). Key knobs:

- `PLEX_URL` / `PLEX_TOKEN`, `OLLAMA_ENDPOINT`
- `BASE_CURATOR_MODEL` (`gemma4:31b`) / `BASE_SUMMARIZER_MODEL`
  (`granite4.1:8b`, backup `granite4.1:3b`) / `EMBEDDING_MODEL`. The summariser
  is the enrichment-throughput bottleneck — it was switched gpt-oss:20b →
  granite4.1:8b (≈3.9× faster, *more* accurate, see §15). After changing it:
  edit `.env` → `python build_models.py` (rebakes the `curatarr-*` tags, bakes
  `num_ctx 8192`) → bump `_PROMPT_VERSION` → restart → `force=True` re-enrich.
  Don't pick on prose vibe — fact-check (§15 nemotron warning).
- `RADARR_URL`/`_API_KEY`, `SONARR_*`, `LIDARR_*`
- `TMDB_API_KEY` (required for movies/shows), `OMDB_API_KEY`,
  `LASTFM_API_KEY`, `SPOTIFY_CLIENT_ID`/`_SECRET` (all optional)
- `SYNC_INTERVAL_HOURS` (24), `ENRICHMENT_TTL_DAYS` (90),
  `ARR_PRE_ENRICH_BATCH` (80), `ARR_LISTING_TIMEOUT_S` (240: Lidarr computes
  per-artist statistics before it answers a full listing)
- `ENABLE_DOCS` (default False — gates `/api/docs`), `ALLOWED_HOSTS`,
  `CF_ACCESS_*` (SECURITY.md)
- `EXTRA_GAME_PROCESSES` — comma-separated .exe names that pause the LLM;
  `GPU_PRESSURE_GATE`, `LLM_CPU_LANE` / `_THREADS` / `_MIN_FREE_MB` (§11)
- The second machine: `PC_AGENT_*`, `OLLAMA_PC_ENDPOINT`, `PC_*` profile and
  bases, `ENRICH_ON_PC`, `OLLAMA_USE_MMAP` / `PC_OLLAMA_USE_MMAP`
  (docs/USAGE.md, docs/NAS_MOVE.md)

---

## 17. Security posture — code-side notes (SECURITY.md is current)

[SECURITY.md](SECURITY.md) is the current posture: the threat model, who can
log in, sessions, first-run setup, connection tests and service addresses,
what a member can make the server do, third-party text in prompts, bodies
from the outside and the known dependency advisories. This section keeps
what a code change needs on top of it: where each control lives and why it
lives there. The 1.0 audit's bullets come first and still hold; the 2026-10
ones close the list.

- Local-LLM only; no hosted-model calls.
- ChromaDB telemetry disabled (`anonymized_telemetry=False`).
- `marked.js` bundled locally (no jsDelivr CDN call), and its output is
  sanitized: `DOMPurify.sanitize(marked.parse(…))` before every innerHTML
  write — marked passes raw HTML through by design, and LLM output can echo
  text from external metadata sources.
- CI scanning: CodeQL on pushes/PRs, Bandit, OSV-Scanner and Scorecard.
  The LLM security scan (GPT-4o on changed files and a weekly sweep) was
  removed on 2026-10-25: it read one file at a time, so its highs were
  mostly cross-file false positives, and it failed every run once its API
  credit ran out.
- **Image proxy** (`src/routers/image_proxy.py`): all external poster URLs
  go through `/api/image/proxy` so TMDB/Deezer don't see per-click browsing.
  Host whitelist + image-only content-type + 5 MB cap + no-auto-redirect
  (whitelist re-checked per hop). **No auth gate** — browsers can't attach
  Bearer headers to `<img>` tags, so the whitelist is the security boundary.
- Setup wizard warns on non-private (public) endpoint URLs.
- `.gitignore` excludes `.env*`, `*.db*`, `chroma_db/`, `data/cache/`,
  `scripts/dev/`, `.claude/`, sqlite CLI binaries.
- **Admin-curation boundary** (`efd11bf`). The first Plex user is the admin;
  shared-library curation is admin-only. Deletions
  (`/recommendations/deletions*`), library config + orphaned recovery
  (`/libraries/orphaned`, `repair-orphans`, `cleanup-orphans`), and reclassify
  (`/library/reclassify/*`) all `require_admin`. Defence in depth: hidden nav
  items + a `showView()` guard for `deletions`/`curation`/`reclassify`/`report`
  + the endpoint checks — the **endpoint check is the real boundary**, the UI
  gates are convenience. Non-admins keep their own taste / recs / chat.
- **Service URLs and the secret sent with them** (`src/services/endpoint_policy.py`,
  2026-10-10 audit). The setup wizard and the library panel share one
  policy for every operator-supplied endpoint (Plex, the arrs, Ollama,
  SoulSync, Shoko). `validate_service_url` admits an http(s) base URL without
  credentials, query or fragment, because a `#` or `?` would cut off or
  swallow the fixed API path the clients append and turn "Test connection"
  into fetch-anything; link-local, cloud-metadata, multicast and unspecified
  literals are refused, LAN and loopback stay legal. A literal check cannot
  see where a NAME points, so `resolved_refusal` applies the same rules to
  every address a name resolves to, right before a test connects, and treats
  a lookup that errors or times out as a refusal. `secret_for_target` sends
  the SAVED key only to the saved origin (scheme, host, port); a new address
  needs its own key, or typing a URL would undo the integrations panel's
  write-only masking. Any new endpoint field goes through both.
- **Host allow-list** (`HostAllowListMiddleware`, `ALLOWED_HOSTS`). The
  DNS-rebinding defence: a rebound page reaches the server under the
  attacker's own name, so a Host that is not listed is answered 400 before
  auth, the API or the static files see it. Opt-in and empty by default,
  because any fixed list would lock out installs reached by IP, hostname or
  a proxy's name; the loopback names always pass.
- **Cloudflare Access at the origin** (`CloudflareAccessMiddleware`,
  `src/services/cf_access.py`, `CF_ACCESS_HOSTS` / `_TEAM_DOMAIN` / `_AUD`).
  Access gates the public name, but the reverse proxy answers anyone who
  reaches the home IP with that name. So under a listed Host a request must
  carry a valid `Cf-Access-Jwt-Assertion` for the app's audience, else 403;
  a listed Host with team or audience unset refuses everything, and any
  other Host (the LAN address, localhost) is never checked, so local use
  never depends on Cloudflare. RS256 is verified in the module, without the
  `cryptography` package: the decrypted signature is compared byte for byte
  with the one valid PKCS#1 v1.5 encoding and nothing is parsed out of it,
  because the parsing is where signature forgeries live. The team's keys are
  cached for an hour and refetched when a token names an unknown one.
- **Middleware order** (`src/main.py`, outermost first): security headers →
  Host check → Cloudflare Access → token refresh → slow-request log → gzip →
  CORS → routes. A refused Host or token never reaches auth or the static
  files, and the refusal still carries the security headers. All of the
  app's own middleware is pure ASGI, so SSE streams pass untouched.

---

## 18. Library reclassification (`src/services/library_sorting.py`, Curate → Reclassify)

Admin tool that audits Sonarr for series filed in the wrong library and moves
them. Two phases; read-only scan, explicit write.

**Detection (`scan_misclassified`, read-only).** A series' *true* category:
- on the anime-lists **AniDB** mapping (`tvdb_to_anidb`) → anime;
- else a TMDB `/find` lookup (cached in `app_state` under `tvdb_meta:<tvdbId>`)
  yields `(origin_country, is_animated)`. **Asian origin AND the Animation
  genre (id 16) → anime.** Asian-origin-but-not-animated is Japanese
  *live-action* (tokusatsu, dramas) and belongs in TV — the Animation-genre
  check is what stops Spider-Man (1978) / Ultraman / Bloody Monday landing in
  anime.
- no origin at all → fall back to the Sonarr `genres`: `"Anime"` ⇒ anime; no
  animation genre at all ⇒ live-action ⇒ TV; only a generic `"Animation"` ⇒
  `uncertain` (the admin picks → TV / → Anime per card).

TMDB is queried only where origin can change the answer (anime-lib non-AniDB,
or TV-lib titles with anime-ish settings), so the full ~3.6k-series sweep is
~15 s, not a 1000-call timeout. Results group into `to_tv` / `to_anime` /
`fix_settings` (right library, wrong `seriesType`/quality profile) /
`uncertain`. Roots + profiles are resolved live, never hardcoded (anime
profile = the one with "anime" in its name; TV = the rest).

**Apply (`apply_reclassify`, the only write path).** Per item:
1. `PUT /api/v3/series/editor` with `seriesType` + `qualityProfileId`, plus
   `rootFolderPath` + `moveFiles` when the root changes (see §15 on why the
   editor endpoint, not `PUT /series/{id}`).
2. **Re-file inside Curatarr without re-enriching.** The enrichment skip-key
   is `(title, media_category)` (§5), so a category change would otherwise
   mismatch → full re-fetch + re-embed + LLM. Instead, four cheap in-place
   updates keep the live `classify_sonarr_category(updated)` == the persisted
   category: `enrichment_status.media_category`, `arr_enrichment_status.
   category`, the MetadataCache profile key `enriched:{cat}:{key}` (copied
   old→new), and the ChromaDB vector's `domain`/`media_type` quarantine
   metadata (metadata-only, no re-embed). The new category is
   `classify_sonarr_category(updated)` (which honours `seriesType` OR the
   `"Anime"` genre), so the rare TVDB-"Anime"-genre-on-a-Western-title edge
   case stays internally consistent — no drift, still no re-fetch.

Nothing in the apply path re-fetches metadata, re-embeds, or calls an LLM.

---

## 19. Module index — the satellites

One line per file the sections above don't already anchor, plus an index
line for each file whose design lives in a §0 row, §4 or §17, with a pointer
there. This is the map, not the territory: when one of these grows a real
design decision, promote it to its own section (or a §0 delta row) instead of
growing this list.

### Entry points & platform
- `src/tray_app.py` — Windows system-tray launcher (pystray + uvicorn worker
  thread); the production entry point, vs `start.bat`'s dev console.
- `src/paths.py` — SSOT for filesystem anchoring (`ROOT`/`DATA_DIR`),
  CWD-independent so tray/autostart/frozen builds resolve data + frontend.
- `src/log_setup.py` — idempotent logging bootstrap: rotating file handler
  always; console handler only when a real stderr exists (pythonw has none).
- `src/winproc.py` — `NO_WINDOW`, the subprocess flags for a console program
  started from the tray's process: under pythonw Windows gives each such child
  a window of its own, so `nvidia-smi` (the GPU-pressure check) flashed one and
  could take focus from the fullscreen game the check exists to protect.
  Empty off Windows; stdlib only, because `deps_check` imports it before the
  dependencies exist.
- `src/deps_check.py` — pinned `requirements.txt` vs. the running
  interpreter (stdlib only, so it runs before the dependencies exist).
  `start.bat` and the tray call it before the first import and run pip on
  a missing or drifted pin; the server only reports (lifespan log line,
  `GET /api/system/dependencies` for Settings → Maintenance). Replaced the
  batch sentinel import, which proved presence, not version, and named an
  unpinned `Crypto` that made fresh installs run pip on every start.
- `src/deps_lock.py` + `lock/requirements.txt` (+ `lock/requirements-*.in`
  → `.txt` for the CI tools) — the tested install written down and
  hash-pinned: a universal pip requirements file from `uv pip compile
  --universal --generate-hashes` (`--compile`; uv is a one-time tool
  install) with a marker where a package is platform-specific and the
  sha256 of every distribution file. It is the source of truth for
  versions: CI installs it with `--require-hashes`, the dependency graph
  reads it for security alerts and Dependabot's security PRs, and
  `requirements.txt` repeats the lock's versions for the direct pins
  (`--apply` / `--sync-pins`; never ahead of the lock —
  `tests/test_deps_lock.py`). Dependabot's VERSION PRs for pip are off
  (2026-09-25): it edits the compiled lock line by line without a
  resolver and moved pydantic-core past what pydantic pins; the lock is
  refreshed with `--compile`, which resolves and keeps what it can, or
  `--compile --upgrade` for the deliberate refresh that moves every version
  to the newest the pins allow. The lock follows
  the install and never lowers anything: `--apply` raises a package below
  its line through a temporary hashed file (`pip install --require-hashes
  --no-deps`), rewrites the line for anything installed above it with
  hashes fetched from PyPI (offline: the line stays), and leaves entries
  its marker rules out here alone. Server: lifespan log line + the `lock`
  block of `GET /api/system/dependencies`, shown in Settings → Maintenance.
  A fresh environment is filled from the lock itself (`deps_check.install_source`,
  2026-09-27): whenever nothing installed stands above the lock and nothing
  is installed that it does not know, the launchers run `pip install
  --require-hashes -r lock/requirements.txt` instead of requirements.txt,
  whose unpinned transitive packages would float past the tested set. An
  empty install no longer reads as "lock matches": missing pins count.
- `src/services/shutdown_bridge.py` — import-free callback registry letting
  the tray intercept the web shutdown endpoint instead of relying on SIGINT.
- `src/services/bg_tasks.py` — keeps strong references to fire-and-forget
  asyncio tasks so they aren't garbage-collected mid-run.
- `src/self_update.py` — fast-forward-only update of a desktop checkout to
  `origin/main` (the tray's "Update now", the agent's `/update`); refuses
  off main, with local changes or with local commits (§0 Updates).
- `src/services/build_info.py` — which build runs: the image's baked sha,
  else HEAD read from `.git` without a git binary; read once per process.
- `src/services/update_check.py` — the admins' "Update available": anonymous
  GitHub, at most hourly, only a build that is ahead counts (§0 Updates).
- `src/services/model_autobuild.py` — the container's background build of
  the main host's missing bakes (`AUTO_BUILD_MODELS`); never the node's.

### Database concurrency (§4)
- `src/database/runner.py` — `run_read` (thread pool; WAL readers run side
  by side) and `run_write` (one dedicated writer thread); a cancelled caller
  does not abort a write already running. `shutdown_writer` drains it.
- `src/database/loop_guard.py` — engine hook that records any statement run
  on the event-loop thread, by call site (`GET /api/system/loop-guard`,
  `CURATARR_LOOP_GUARD` warn / raise / off); `sqlite_connect` brings the
  other SQLite files under the same check.

### Compute nodes (§0 Compute nodes)
- `src/pc_agent.py` — runs on the node, started by its tray when
  `PC_AGENT_TOKEN` is set (no token, no agent). `GET /status` reports raw
  readings, process names and GPU memory only (no paths, command lines or
  user data); `POST /update` takes no parameters. The token rides in
  `X-Agent-Token` and is compared in constant time.
- `src/gpu_engine.py` — each program's share of the card's 3D engine from
  the Windows performance counters (Task Manager's GPU column), so an
  unclassified game shows within seconds instead of after the 45 s
  GPU-pressure hold; empty off Windows.
- `src/services/pc_host.py` — the server's view of the node: the 5 s poll,
  available / draining / gone, `generation()`, its build state and its name
  (the owner's rename over the one it reports).
- `src/services/pc_guard.py` — `ollama_client()`, the httpx client for model
  calls; a request to the node is cancelled when the node leaves and raises
  `PcLost` (an `httpx.ConnectError`).
- `src/services/ollama_hosts.py` — the role → host table (`host_for`,
  `ollama_for`, `PcUnavailable`). Code that manages models rather than using
  them reads the endpoint directly; `tests/test_ollama_hosts.py` keeps that
  list closed.
- `src/services/verdict_cache.py` — deletion verdicts and pitches reused
  while the fingerprint of everything that decides them holds.
- `src/routers/hosts.py` — `/api/hosts`: the agent's goodbye (token, no
  login); for admins the node's state, rename and update.
- `src/services/deletion_queue.py` — "Run analysis" while the node is away
  or busy: the judge runs only there, so the request is kept (one per
  install, the last click wins, in app_state) and started by the first poll
  that finds the node free (`pc_host` calls `kick`). Cleared before the run
  starts, re-checked for an admin, dropped after 7 days so a long absence
  does not fire a stale analysis. The scheduled scan needs none of it: it
  stays due and retries every tick.

### Wrap-Up (§0 Wrap-Up)
- `src/services/wrapup_facts.py` — every number of a wrap-up, decided in
  SQL before any model sees it; blocking, called through `run_read`.
- `src/services/wrapup_story.py` — the numbered fact sheet, the forced-JSON
  story, `check_story`, one repair turn, `fallback_story`; the "Ask about
  it" context block; `pregenerate_all` for the custodian.
- `src/services/wrapup_narrative.py` — the narrative facts (ritual,
  comfort genre, of the month, night shift, the first title) in the
  member's zone.
- `src/services/wrapup_cards.py` — "Your week", "Your month/year is ready"
  on the chat landing; reads only, never generates.
- `src/services/wrapup_store.py` — the `wrapups` table (load through
  `run_read`, saves through `run_write`).
- `src/routers/wrapup.py` — `/api/wrapup`: the live facts, `/generate`
  (background task), `/status/{task_id}` for the ticker, `/playlist` (the
  period's titles as a Plex playlist on the user's own account).
- `frontend/js/wrapup.js` — the view: a tile only for a section the server
  sent, every number from the facts; admins get the curation ledger
  (`report.js`) at the foot.

### Security surface (§17)
- `src/services/endpoint_policy.py` — `validate_service_url`,
  `resolved_refusal`, `secret_for_target`: where an operator-supplied
  service URL may point and which saved secret may go with it.
- `src/services/cf_access.py` — Cloudflare Access token check for the
  public names (RS256 without `cryptography`, key cache, claims).
- `src/middleware.py` — the app's pure-ASGI stack: `SecurityHeadersMiddleware`,
  `HostAllowListMiddleware` (`ALLOWED_HOSTS`), `CloudflareAccessMiddleware`,
  `TokenRefreshMiddleware`, `SlowRequestLogMiddleware`.

### Time & history
- `src/services/user_time.py` — whose clock: the user's IANA zone, else the
  machine's, with the offset in force at each row's time; `local_sql` for
  queries (§0 Whose clock).
- `src/services/library_ledger.py` — what arrived when and what left, from
  the tech walk; only a complete walk marks anything gone (§0 Library
  ledger).
- `src/services/household_compare.py` — the opt-in household block of a
  wrap-up: rank by hours, the radar median, "only you"; only among members
  who said yes, worked out on open, never stored.
- `src/services/music_tags.py` — Plex's moods and styles per artist, album
  and track, walked through Plex's filter lists; a play takes its track's,
  else its album's, else its artist's (by key, else by names).
- `src/services/judge_record.py` — the judge's verdicts past the cache's
  30 days, and what a protection was before it was lifted or changed.
- `src/database/orphaned.py` — plays of media gone from Plex: the orphan
  cleanup marks them, every ORM read hides them, the Wrap-Up asks for them.
- `src/services/debate_outcome.py` — what a deletion debate settled and who
  convinced whom; rule first, the summarizer only where reading the debate
  is the only way to tell (§0 Debate outcomes).
- `src/services/credits_index.py` — who made what, filed from the raw cache
  into `media_works` / `media_credits` / `media_companies`; an entry outlives
  the cache row it came from (§0 Credits index). `credits_of` reads one title.

### Model updates (§0 Model updates)
- `src/services/model_updates.py` — the daily registry check
  (`check_updates`), the pin → pull → bake → both-sides test
  (`run_comparison`), `rollback`, `recover_interrupted` and `decide`.
- `src/services/model_update_eval.py` — the current weights' blind review in
  both orders, and `guards`, the measured rules that overrule it.
- `src/routers/model_updates.py` — `/api/system/model-updates` (admin):
  status, check, compare, evaluate, decide; one test or review at a time.

### Swipe (§0 Swipe)
- `src/services/swipe.py` — the stack (`next_cards`, `chain_cards`), answers
  and undo, `load_swipe_entries` for the taste rebuild.
- `src/routers/swipe.py` — `/api/swipe`: next, answer (the watchlist write
  and the background rebuild), undo, stats.
- `frontend/js/swipe.js` — the view: the card deck, gestures and keys.

### LLM calls (§0 LLM failures by kind, prompts that fit)
- `src/services/llm_errors.py` — `post_chat`, the non-streaming call that
  raises `LLMCallError` with its kind; `INFRA`, `REPAIRABLE`, `DESCRIPTIONS`.
- `src/services/prompt_budget.py` — `est_tokens`, `budget_for`, `fit` (the
  judge's evidence), `fit_messages` (chat history), `keep_tail` (a debate
  transcript, newest part kept).

### Upkeep (custodian)
- `src/services/limbo_reconcile.py` — deletions with an unknown outcome,
  settled only by the service's own 404 (§0 Limbo reconcile).
- `src/services/retention.py` — retired rows past their horizon, a
  discussed row kept (§0 Retention).
- `src/services/vector_reconcile.py` — the database against the vector
  store, never destructive (§0 Vector reconcile).

### Routers not covered above
- `src/routers/imports.py` — GUI data imports (Spotify listening-history
  upload + run); upload open during setup, running the import is admin-only.
- `src/routers/messages.py` — proactive-messages API (list unread, mark read).
- `src/routers/stats.py` — admin-only curation ledger, backed by
  `src/services/curation_stats.py` (live SQL: monthly resolutions, GB freed,
  Stubbornness Index, redundancy, taste evolution), rendered as the owner's
  section at the foot of the Wrap-Up; `/debate-labels` shows the
  summarizer's who-convinced-whom labels next to the owner's own words.

### Chat & curator satellites
- `src/services/app_context.py` — SSOT for what the curator is told about the
  app's own UI (buttons/badges/verdicts), drift-tested against
  the frontend files (`index.html` + `js/app.js`, read through
  `tests/frontend_files.py`). Never inline app knowledge in routers.
- `src/services/episode_context.py` — the Sonarr episode a user stopped at
  plus the next one ("you stopped at S1E9, next up …").
- `src/services/watch_status.py` — per-user watch status from Plex-synced
  `watch_history` (NOT Tautulli): "unseen but curious" vs "watched and moved on".
- `src/services/verification_session.py` — post-enrichment one-at-a-time
  taste-verification questions (abandoned items, conflicting signals, outliers).
- `src/services/curator_principles.py` — autonomous principle-learning from
  owner debates: extract → novelty-check → re-inject into the judge.
- `src/services/stream_tickets.py` — short-lived one-time tickets for SSE
  streams, so JWTs never ride in URL query strings.

### Deletion-debate evidence (deterministic, LLM-free)
- `src/services/album_dossier.py` — album-level music evidence (type, stock,
  community standing, owner listening, style) from Lidarr + Last.fm + Discogs.
- `src/services/lidarr_discography.py` — one-call Lidarr album summary
  (counts, disk size, monitored-but-fileless ghosts); uncached by design.
- `src/services/reception.py` — community reception (AniList/MAL/Jikan/TMDB
  reviews) for obscure titles.
- `src/services/mal_source.py` — MyAnimeList for anime: the official API v2
  with `MAL_CLIENT_ID` (header `X-MAL-CLIENT-ID`, public data, no login;
  fields mapped onto the Jikan supplement's shape), Jikan without it, and
  Jikan paused for an hour after three failures in a row (down since
  2026-08-28). Reception takes MAL's scores from the official API; written
  reviews exist only on Jikan, so while it is paused the AniList reviews
  carry the verdict instead of holding every anime with a MAL id.
- `src/services/listenbrainz.py` — global listening for music evidence: one
  call per artist (`top-release-groups-for-artist`) feeds both the artist
  debate's "Global listening" line and the album dossier's rank inside the
  artist's catalogue. Needs `LISTENBRAINZ_TOKEN` (the popularity endpoints
  went behind auth; the few keyless 200s are cached answers, not a contract):
  without one every fetch is transient, nothing is stamped, and the evidence
  appears once a token lands. Its own ~1 req/s gate, apart from MusicBrainz's,
  so it never stalls that lane; only a digest of the 100-500 KB answer is cached.
- `src/services/studio_notes.py` — Wikipedia "what this studio is known for"
  reputation notes, cached ~forever.
- `src/services/wikidata.py` — structured Wikidata facts (adaptation-source
  authors, named awards) as archive-pillar evidence; imdb-id shape-guarded
  before it ever reaches SPARQL.
- `src/services/subtitle_signals.py` — LLM-free pacing/rhythm signals from a
  subtitle track, execution-side evidence for the judge.
- `src/services/subtitle_provider.py` — generic HTTP adapter contract for an
  optional self-hosted subtitle service (no bundled provider).

### Identity & mapping
- `src/services/external_ids.py` — harvests IMDb/TVDB/TMDB ids the *arrs
  already hold (id-join first, name+year veto second).
- `src/services/anime_mapping.py` — community anime-lists AniDB↔TVDB↔AniList
  crossref, cached + refreshed weekly.
- `src/services/anime_offline.py` — manami-project anime-offline-database as
  a SQLite-cached fallback lookup.
- `src/services/discogs_offline.py` — streams the monthly Discogs CC0 masters
  dump into a local per-artist style vocabulary (no API token needed).

### Anime neighbour
- `src/services/shoko_client.py`, `shoko_sync.py`, `shoko_pins.py`,
  `src/routers/shoko.py` — the optional Shoko Server (`SHOKO_URL`,
  `SHOKO_API_KEY`, one key per Shoko user). Custodian task `shoko_watched`
  (6 h) marks each finished anime play watched there with its own date
  (`PUT /Episode/{id}/UserData`; a play no later than Shoko's last one plus
  the same-viewing window is left alone). The Shoko episode comes from the
  media server's file (name + size, `/File/PathEndsWith?include=XRefs`),
  else TMDB, else the current copy of a play whose key is gone (same series
  title, season and number), else an owner pin per series and season
  (`ShokoPin`: link to an AniDB anime from an episode on, that episode =
  its regular episode 1, so a season AniDB splits into cours takes one
  link per cour; or skip). Ledger `shoko_watch_sync`: one report per play, unmatched ones
  retried weekly five times. Routes and shapes are read from the live
  server's OpenAPI (`/swagger/v3/swagger.json`, Shoko 6); writes never
  leave a test process.

### Music neighbours
- `src/services/soulsync_client.py` — client for the SoulSync LAN
  neighbour's music-metadata API; never triggers its downloads. Reads the
  library, the watchlist (followed artists: a 5-star taste signal and a
  deletion veto) and the Liked Songs on the wishlist (a 4-star signal); its
  one write, `block_artist`, puts a deleted artist on the blocklist and
  never runs from a test process (`writes_allowed`).
- `src/services/music_catalog_sync.py` — nightly SoulSync→Lidarr catalog sync
  (structure index only — never fights SoulSync over files).
- `src/services/spotify_import.py` — engine behind the GUI Spotify upload;
  root `import_spotify.py` is a thin CLI wrapper over it.

### Library-curation satellites
- `src/services/library_memory.py` — shared seen/owned title indices so rec
  lanes never resurface an already-watched or already-owned title.
- `src/services/upgrade_curation.py` — the inverse of the deletion flag:
  strong love-signal, weak file quality.
- `src/services/collection_designer.py` + `src/services/plex_collections.py` —
  LLM-designed rotating themed collections from owned candidates; the Plex
  write layer is prefix-scoped so it never touches Kometa's collections.
- `src/services/plex_watchlist.py` — adds a title to the discussing user's
  own plex.tv watchlist when they declare watch-intent in a deletion debate.
- `src/services/orphan_repair.py` — remaps watch-history rows whose Plex
  library section no longer exists.
- `src/services/kb_overview.py` — SSOT for "how much is enriched/vectorized";
  replaced three counters that disagreed with each other.
- `src/services/archive_backfill.py` — coverage-gated on-demand backfill for
  metadata sources still catching up (vs the custodian's gentle pace).
- `src/services/display_tags.py` — the tags a deletion card shows: up to
  three genres from the enriched profile (the arr's list only without one)
  minus what the tab already says, rarest in the library first, plus up to two
  short themes, skipping any that more than 5 % of the category carries.
  Rarity is counted per category and kept six hours; a failure leaves the
  cards on their plain genres.

### Shared plumbing
- `src/services/arr_client.py` — the async Plex / Radarr / Sonarr / Lidarr /
  Tautulli clients over one `MediaService.request`: a token bucket per client
  that sleeps outside its lock (sleeping inside queued every caller behind
  one wait), 429s and timeouts on separate retry budgets, Retry-After read in
  both its forms and capped at 120 s, 10 s to connect. A full library listing
  gets one long READ timeout (`ARR_LISTING_TIMEOUT_S`) and no second attempt,
  because a retry costs the arr the same full scan; its 15-30 MB of JSON is
  decoded in a worker thread. `classify_sonarr_category` is the one
  show-vs-anime rule (`seriesType` or an "Anime" genre): two copies once
  cached a series under one category and looked it up under the other.
- `src/services/http_client.py` — `tls_context()`, the one SSLContext every
  `httpx.AsyncClient` in `src/` passes as `verify=`: httpx otherwise builds
  a context per client and loads the whole CA bundle (~50 ms of event-loop
  CPU), and clients are opened per title and per probe. Built in the
  lifespan before serving; `tests/test_loop_free_probes.py` refuses a client
  without `verify=`.
- `src/services/rating_value.py` — a score or a vote count as a number, or
  none: Jikan/MAL put the content rating ("PG-13 - Teens 13 or older") in
  `rating`, music sources write "N/A", and the summariser echoed its own
  "N/A/10 (N/A votes)", where one bare `float()` stopped every deletion scan.
  `prompt_rating` writes "unknown" rather than a score that is not one.
- `src/services/household.py` — `is_admin_user`, for background paths that
  apply owner-only data (the owner's SoulSync follows in the music taste
  rebuild) or do owner-only work (principle capture for the global judge).
  Fails closed: an unreadable row reads as a member, so owner data is
  withheld for one run rather than leaked into a member's profile.

### Models & embeddings
- `src/services/model_catalog.py` — benchmark-verified Ollama model catalog +
  VRAM-aware recommendations for the setup wizard.
- `src/services/embedding_migration.py` — one-shot v2 embedding-corpus build
  in a parallel Chroma collection, sanity-gated before flipping the profile.
- `src/services/model_check.py` — which models this install needs and which
  are missing, one list for the tray's preflight and `build_models.py
  --check` (start.bat goes through it). The embedding model is the stored
  profile's, not `EMBEDDING_MODEL`, and an Ollama that does not answer is
  reported as exactly that (`None`), never as every model missing, so no
  launcher starts pulling on it. Also each host's bake plan (`host_plan`), the
  curator VRAM-fit warning, and `embedding_identity`: vectors from other
  weights do not compare even under the same model name, which a move to
  another machine has to check before its first embedding.
- `src/services/setup_minibench.py` — after the bake, the wizard runs the
  curator through about a minute of production calls on this machine
  (`POST /api/setup/minibench`: warm-up, two judge verdicts with the
  production prompt, schema and parser, one card note), because the hardware
  matrix only estimates cards it was not measured on, and a busy home server
  judged 2-3.7x slower than predicted. The fixtures belong to a fictional
  owner, so no user's data reaches the check; the timing is the result, not
  the verdict. Verdicts rank spill > unreliable > slow (median verdict over
  20 s) > ok; a nearly full card counts as a spill only when generation is
  also under 60 % of the expected speed.

### Schemas
- `src/schemas/user.py` / `chat.py` / `recommendations.py` — Pydantic shapes;
  `chat.DiscussContext` carries server-owned references (proposal, proactive
  message, principle, starter, watched-title) the backend resolves itself —
  client-supplied title/reason text is never trusted.

### CI & benchmarks
- `.github/workflows/` — tests, CodeQL, Bandit, OSV-Scanner and
  Scorecard; every third-party action is pinned to a full commit SHA.
  `tests/test_workflow_pins.py` keeps the pins full-SHA and one action
  family on one commit: Dependabot bumps `codeql-action/init`, `/analyze`
  and `/upload-sarif` (and the OSV reusable workflow and its PR twin) as
  separate PRs, and CodeQL refuses to run when init and analyze differ
  (2026-09-21). `.github/dependabot.yml` groups those families into one PR.
  `osv-scanner.toml` (root) holds the four documented chromadb exceptions;
  `tests/test_osv_config.py` keeps each one explained in SECURITY.md and
  both OSV jobs on the same scan-args (`--no-resolve`; `--config` so the
  exceptions reach `lock/requirements.txt`, the tested install's closure).
- `tests/benchmarks/` — model/prompt benchmarking harness (curator_bench,
  tournament_bench, auto_benchmark, num_ctx_bench, curator_pipeline_bench +
  `model_baselines.csv`); measurements land in `docs/BENCHMARKS.md`.

---

## 20. Frontend grammar (`frontend/`: index.html, css/app.css, js/app.js)

**Files (2026-09-11, PR 1 of 3 of the split).** The 7,376-line single file
became three: `index.html` keeps the markup and its 217 inline `on*=`
handlers; `css/app.css` carries the stylesheet with the DESIGN LANGUAGE
header; `js/app.js` is the whole app logic as ONE ES module (a
`script type=module` tag). A module has its own scope and runs strict, so
the 130 functions the inline handlers name are handed to `window` in the
`Object.assign(window, {...})` block at the end of app.js — `tests/
test_frontend_hygiene.py` pins that the block covers every handler and
nothing more; an eslint `no-undef` pass found no implicit globals. main.py
serves the three with `Cache-Control: no-cache` so a `git pull` + reload
never mixes an old module with new markup. Tests read the frontend through
`tests/frontend_files.py` (`markup()`, `styles()`, `script()`,
`everything()`), so the next split touches one place. Next: PR 2 splits
app.js by view with a `state.js` for the shared globals; PR 3 replaces the
inline handlers with event delegation and drops `'unsafe-inline'` from the
CSP. The extraction itself was done by Jules from a written brief; the
readers, the encoding-safe helper and the strict-mode audit were finished
here.

**Modules (2026-09-13, PR 2 of 3).** app.js became an entry module (imports,
`init()` holding the former top-level statements, the window export block,
`init()` call) plus 25 modules cut along the banner comments — `api.js`,
`ui.js`, `nav.js`, `notifications.js`, `setup.js`, `spotify_import.js`,
`auth.js`, `report.js`, `reclassify.js`, `arr.js`, `chat.js`, `history.js`,
`settings.js`, `library_settings.js`, `recs.js`, `deletions.js`,
`curation.js`, `kb.js`, `picker.js`, `music.js`, `libraries.js`, `admin.js`,
`activity.js`, `game.js` — and `state.js`, one exported object holding the
20 mutable variables that more than one module touches (an imported `let`
cannot be assigned, so `state.token`, `state.currentUser`, ...). Modules
contain declarations only; the import graph has cycles, which is safe
because nothing runs at import time. `frontend/js/package.json` says
`{"type": "module"}` so `node --check` parses ESM. Rules that came out of
the review: state.js must carry the ORIGINAL initial values (a `null` where
the code expects `[]` or `{}` is a TypeError on the first `.map`); handler
code — every `on*=` attribute, every string menuHtml/pagerHtml/emptyHtml/
_errHtml turns into a handler — runs in the global scope, so it may name
only exported functions and browser globals, never `state` or a former
top-level variable (`reloadDeletions()`/`reloadRecs()` exist for that), and
every identifier in it counts, not just the leading call (five Curation
`ontoggle="if (this.open) loadX()"` sections and one second-statement
`_syncDelPosterVisual(this)` were missing from the export block since PR 1).
`tests/test_frontend_hygiene.py` pins all of it; `tests/frontend_files.py`
concatenates the modules for every needle test. Next: PR 3, event
delegation in place of the 217 inline handlers, then `'unsafe-inline'`
leaves the CSP.

**Delegation, first half (2026-09-13, PR 3a).** The 109 inline handlers in
`index.html` are data attributes now: `data-action="name"` fires on click,
`data-on-<event>="name"` for change, input, keydown, submit, drop, dragover,
dragleave (bubbling) and toggle, blur, error (capture phase — they do not
bubble); `data-args` is a JSON array in which `"$el"` becomes the element
and `"$event"` the event, and WITHOUT `data-args` the function is called
with no arguments (never the element implicitly). One dispatcher in app.js
resolves `closest('[data-action]')` and calls `actions[name]`, a registry of
the same functions the window block used to expose plus eight one-line
wrappers for handlers that carried a statement or a condition
(`curationSection`, `searchOnEnter`, `onRecentOnlyChange`, ...). Hygiene
tests pinned at that point: zero `on*=` in markup, every action name in the
registry, every `data-args` valid JSON, the window block equal to the
template references.

**Delegation, second half (2026-09-13, PR 3b).** The 108 template handlers
in `frontend/js/*.js` use `act(name, ...args)` (click) and
`actOn(event, name, ...args)` from `ui.js`, which render the same data
attributes with the args JSON-encoded and escaped — raw values go in, never
`esc()`; the tokens `EL` and `EVENT` stand for the element and the event.
Click arguments live in `data-args`, a `data-on-<event>` handler's in its
own `data-args-<event>` (the dispatcher falls back to `data-args`, which
hand-written markup uses): one element can carry a click handler and any
number of event handlers with different arguments, where one shared
attribute would keep only the first — the parser drops duplicates.
Handlers that read the element or the event at event time became eighteen
one-line wrappers in their owning modules (`onBrowserSort`, `onDelCheckbox`,
`keyActivate`, `goToView`, ...). The four string-to-handler helpers take
attribute text: `_errHtml(e, act('loadUsers'))`, `menuHtml` items carry
`action`, `emptyHtml(html, label, act(...))`, and `pagerHtml({offset, limit,
total, page})` calls `page(offset)` for its Prev/Next buttons. The window
block is gone, `const actions = {...}` in app.js is the only table, and
`src/middleware.py` sends `script-src 'self'` and, since 2026-09-25,
`style-src 'self'` (the inline-style budget went to zero: classes for static
styles, `data-style` + `ui.hydrateStyles` for the eight computed values). `tests/test_frontend_hygiene.py`
pins: zero `on*=` anywhere, no globals through `window`, the registry equal
to the set of referenced names in both directions, literal action names, no
variable passed in quotes (`act('goToView', 't.view')` — the mistake the PR
review found in ten places, next to 197 missing imports that eslint's
`no-undef` caught and `node --check` cannot), no leftover `call:` strings
(three menu items had kept theirs and did nothing), and no markup element
whose event handler would fire with the click's arguments.

One file, one visual language. The CSS header (DESIGN LANGUAGE) states the
rules; this section is the map.

**Anatomy.** A view is `.view-header` (h1 + one sentence) → `.toolbar`
(page-level actions on the left, view controls — sort, filter chips, search,
Refresh — on the right in `.toolbar-right`) → content → `.select-bar`
(sticky at the bottom, rendered only while something is selected; the bulk
action lives there and nowhere else). Sub-panels inside a view are
`.section` (`.section-head`: h3 + count badge + `.section-hint`,
`.section-actions` on the right; `.section-body`); collapsible ones are
`<details class="section">`. Sub-views inside a view are `.cat-tabs`
(Knowledge Base: Overview · Needs attention · Maintenance · Music pipeline ·
Profile browser).

**Rows.** `.panel-item`: `.panel-item-head` (`.panel-item-title` with badges,
`.panel-actions` on the right — at most three visible buttons, the rest in
`menuHtml()` as "More", destructive ones last) → `.panel-item-sub` (one
sentence) → `.panel-item-meta` (chips, findings, numbers with the absolute
time in `title=`) → `.panel-item-foot` (progress, log line). Poster rows are
`.card.poster-card` with the same head. Tables (`.tbl`) put actions in the
last column.

**Feedback.** `toast(text, kind)` for outcomes (success · danger · amber ·
info; a toast with `actions` stays until answered and is never evicted);
`btnBusy(btn)` / `btnDone(btn, label, {revertMs, keepDisabled})` for the
pressed button; `setStatus(el, text, kind)` for the inline status next to a
Save / Test button; `_errHtml()` for load failures; `emptyHtml(html, cta)`
for empty lists (`.empty.good` is the happy case). Nothing calls `alert()`,
`confirm()` or `prompt()`.

**Decisions.** `confirmDialog({title, body, confirmLabel, danger, countdown,
reason})` → `{ok, reason}` for every destructive action (delete, bulk
delete, force re-enrich, disable user, lift protection, apply to Sonarr,
shut down); `countdown: 3` is the deliberate pause the delete flow always
had; `reason` is the free-text field Curatarr learns from. Dialogs share one
root (`openModal`/`closeModal`: Escape, backdrop, X, first control focused;
Escape closes an open menu first). The match picker is such a dialog
(`openMatchPicker` → `renderMatchPicker`), one for the Knowledge Base and
the deletion cards.

**Forms.** `trackDirty(form, saveBtn)` keeps a card's Save disabled until a
field changes; the caller's reload re-arms it (Settings → Arr apps /
Integrations / defaults, Settings → Plex libraries). Notifications save on the
toggle itself.

**Utilities.** `.t2 .t3 .t-amber .t-danger .t-success .t-right .t-center .b
.mono .fs-10..13 .row .row-end .stack .grow .mt-4/8/12 .mb-8/12`, `.input`
for controls outside a `.form-group`, `.chip(.active)` for filters,
`.link-num` for a number that opens a list, `.badge-sm`, `.banner(.warn|
.danger|.ok)`, `.spinner`; `[hidden]` wins over any display rule. Inline
`style=` is for data-driven values (a width, a grid template) only.

**Guard.** `tests/test_frontend_hygiene.py` pins the counts of the old
mechanisms and the inline-style budget as ceilings that only go down
(2026-09-06: alert/confirm/prompt/cssText 0; inline styles in JS templates
≤ 218 and in static markup ≤ 60, down from 651 / 198; 2026-09-25: both 0,
`data-style` ≤ 8 for computed values), checks that every
shared helper exists and that no second overlay or toast system appears.
`test_app_context_drift` keeps prompt-visible labels in sync with
`app_context.py`; `test_frontend_syntax` runs every inline script through
`node --check`.

**Navigation (2026-10, the UI audit).** The URL hash names the view —
`#recs`, `#deletions`, `#settings/users` — so a view can be bookmarked,
reloaded and reached with Back/Forward. Sidebar entries are plain `<a
href="#view">` links grouped by intent (Discover · Library · Curate (admin)
· System); a `hashchange` listener calls `nav.routeFromHash()`, which
renders through `showView()`. `showView()` called from code (a glance tile,
a discussion jumping to chat) pushes the hash itself with `pushState`,
which fires no `hashchange`, so nothing renders twice; `routeFromHash()`
also returns early when the hash names the view already on screen. Settings
panes ride along as `#settings/<pane>` via `replaceState`, so Back leaves
Settings instead of stepping through its tabs. A non-admin landing on an
admin view's hash is sent to `#chat`. After navigation the owner asked for,
focus moves to the view's `<h1 tabindex="-1">` (Chat carries a visually
hidden one), and the active entry carries `aria-current="page"`; the first
render after sign-in leaves focus alone. The standalone Libraries and Users
views became Settings panes (`plex-libraries`, `users` — the latter now
with the Spotify import that only the old view had), and Stop server moved
from the nav footer to Settings → Maintenance. Wording rules (one word per
concept, sentence case for controls, nav destinations as proper names)
live in the WORDS block of the stylesheet header.