## Context

Набор capabilities в `openspec/specs/` сложился по ходу пилотной миграции и не
отражает цепочку обработки загрузки. Три проблемы:

1. `recognition` смешивает разбор LLM и работу с метабазами.
2. Поведение ревью размазано: часть — в `web-ui`, основная часть — в не
   перенесённом `docs/specs/review-ux.md`.
3. Звенья цепочки `file-layout`, `download-tracking`, `notifications` в OpenSpec
   отсутствуют — источник истины по ним в `docs/specs/` (`jellyfin-layout.md`,
   `workflow.md`, `architecture.md`).

Разбор цепочки «загрузка → распознавание → матч → ревью → раскладка» (действие →
артефакт в БД) дал естественный набор доменов; приводим capabilities к нему.

Ограничение: **рефакторинг только спек, код не трогаем.** Формулировки переносим
эквивалентными — ни одно нормативное требование не должно измениться по смыслу.

## Goals / Non-Goals

**Goals:**
- Одно поведение — один capability; имя читается без знания кода.
- Полная цепочка представлена в `openspec/specs/` (перенос из `docs/specs/`).
- Источник истины по мигрируемым темам переезжает в OpenSpec; в `docs/specs/`
  остаётся пометка о переезде (как пилот `ingest`).

**Non-Goals:**
- Никаких изменений поведения, схемы БД, кода, конфигурации.
- Не вводим новые сущности домена (напр. «тайтл» — отдельная задача беклога).
- Не дробим метабазу на search/match (решено: одна `metadata-match`).
- Мульти-бот маршрутизация уведомлений — отдельная задача, здесь только фиксируем
  текущее поведение `notifier`.

## Decisions

### D1. Механика переноса требований между capabilities

OpenSpec 1.4.x поддерживает `RENAMED` только внутри одной спеки. Межспековый
перенос оформляем парой: **`REMOVED Requirement`** в исходной спеке (с
`**Reason**`/`**Migration**`, указывающими целевую capability) + **`ADDED
Requirement`** в целевой. Текст требования в ADDED — эквивалент исходного
(перенос, не переписывание). Альтернатива (просто RENAMED) не подходит:
capability меняется, а не имя внутри одной спеки.

### D2. `recognition` → `recognition` + `metadata-match`

Разные действия: «разобрать сигналы моделью» и «найти/подтвердить запись в базе».
`Контракт LLM на оригинальное и локализованное названия` **остаётся в
recognition** (это требование к выходу модели), хотя используют эти поля обе:
recognition — чтобы модель их заполнила, metadata-match — как ключи поиска.

| Текущее требование (recognition) | Назначение |
|---|---|
| Сверка с базой по нескольким названиям | → `metadata-match` |
| Контракт LLM на оригинальное и локализованное названия | остаётся `recognition` |
| Локаль запроса к TMDB | → `metadata-match` |
| Нормализация названий при сравнении | → `metadata-match` |
| Кандидат несёт URL для внешней проверки | → `metadata-match` |

Backfill `recognition` из `docs/specs/recognition.md` (ADDED — сейчас в OpenSpec
этих требований нет):
- Пред-парс имени релиза (`go-ptn`).
- Разбор сигналов LLM в структурированный план (схема: `type`, `title`,
  `original_title`, `year`, `provider_hint`, `files[]` с per-file `season`/
  `episode`, `confidence`).
- Провайдер LLM за абстракцией (`openai-compat`, JSON-mode, ретраи с передачей
  ошибки/схемы; неразобранный после ретраев → review, не failed).
- Модель уверенности и решение auto/review (авто только при подтверждённом матче
  в базе + структурная валидация + согласованность с пред-парсом).
- Роли файлов на краях (sample/extra/ignore, привязка внешних субтитров).

ADDED в `metadata-match` (сам поиск/подтверждение, backfill из `recognition.md`
§3, помимо 4 перенесённых):
- Поиск записи в TMDB/TVDB/TVMaze, сбор кандидатов с дедупом `provider:id`.
- Подтверждение единичного сильного матча → `provider`/`provider_id`/канон. имя/год.
- Опциональность баз: нет баз или нет матча → авто-раскладки нет (граница с
  recognition-решением; здесь — что матч не подтверждён).

### D3. `review` как отдельный capability

Переносим из `web-ui`:

| Текущее требование (web-ui) | Назначение |
|---|---|
| Единый список источников совпадения на ревью | → `review` |
| Ручное добавление источника по id или URL | → `review` |
| Предпросмотр полей источника до фиксации выбора | → `review` |

`Матч с записью метабазы ссылкой` **остаётся в web-ui** — это отображение на
странице загрузки, а не действие ревью.

ADDED в `review` (миграция `docs/specs/review-ux.md`):
- Триггеры входа в review с явной причиной.
- Команды: Применить, Уточнить (подсказка+перераспознавание), Распознать заново,
  Тип, Игнор файла, Позже, Отклонить, Undo, Привязать заново.
- Подсказка (мягкая, интерпретирует LLM) vs override (жёсткий пин;
  перераспознавание не затирает).
- Единый список источников (нейронка наравне с кандидатами) + ручное добавление
  + предпросмотр «превью = применение» (перенесены из web-ui, п. выше).
- Разделение труда транспортов (веб — точные правки, Telegram — быстрые действия/
  эскалация в веб); одно состояние ревью, команды сериализует worker.

### D4. Миграция `file-layout`, `download-tracking`, `notifications` из docs/specs

**`file-layout`** ← `docs/specs/jellyfin-layout.md`:
- Целевые имена фильмов (`Название (Год) [providerid-…]`).
- Целевые имена сериалов (папка с provider-id, `Season xx`, `SxxEyy`).
- Сопоставление источник→цель хардлинками (`save_path`+относит. имя; mkdir 0755).
- Санитизация целевого имени и запрет выхода за библиотеку.
- Never-overwrite (тот же inode → готово; другой файл → коллизия → review).
- Copy-fallback при невозможности хардлинка.

**Владение целевым путём** (`superseded`) и **безопасный undo** (`nlink<=1` →
`ErrLastCopy`) в `file-layout` НЕ дублируем: их доминирующая забота — сверка с
реальностью (присутствие цели определяется по владению; отказ Undo при пропавшем
источнике), и они уже живут более полными версиями в `state-reconciliation`.
`file-layout` описывает акт прямой раскладки; на владение/undo он опирается,
оставляя их дом в `state-reconciliation` (см. ревью дизайна: устранение дубля).

**`download-tracking`** ← `docs/specs/workflow.md` (прямой путь; сверка
разложенного остаётся в `state-reconciliation`):
- Поллинг qBittorrent и сопоставление его состояний с нашими.
- Готовность только когда файлы на месте (не `moving`/`checking*`).
- Таймауты-предохранители: `metaDL` > `magnet_timeout` → failed; `stalledDL` >
  `stuck_after` → stuck; базис возраста — `added_on` (переживает retry/усыновление).
- Ошибка qBit (`error`/`missingFiles`) → failed (`qbit_error`).
- Усыновление раздач по категории **или** тегу, которых нет в БД → `downloading`.
- Владелец переходов — worker под per-download блокировкой (FSM).

**`notifications`** ← `workflow.md` + `state-reconciliation`:
- Уведомление автора о падении (`failed`/`stuck`), включая приёмный `qbit_add`
  мимо поллинга.
- Дебаунс повторных падений одной задачи (мерцающий stalled не спамит).
- Пинг о входе в review и о готовности.
- Уведомление о рассинхроне (перенос из `state-reconciliation`).

| Текущее требование (state-reconciliation) | Назначение |
|---|---|
| Уведомление о рассинхроне | → `notifications` |
| (остальные 11) | остаются `state-reconciliation` |

### D5. `identity` → инфраструктура id; приём → `ingest`

| Текущее требование (identity) | Назначение |
|---|---|
| ULID как первичный ключ сущностей | остаётся `identity` |
| Нормализация и валидация id на входных границах | остаётся `identity` |
| Множество инфохэшей загрузки | → `ingest` |
| Дедупликация приёма по любому из хешей | → `ingest` |
| Атомарность возврата загрузки в активное состояние | → `ingest` |
| Корреляция сущностей в логах | остаётся `identity` |
| Миграция существующих записей | остаётся `identity` |

Примечание: `Атомарность возврата в активное состояние` используется и «Привязать
заново» (review) — но это инвариант приёма/активации загрузки, поэтому его дом —
`ingest`; review на него ссылается.

### D6. Формулировки — эквивалентный перенос

При ADDED в целевой capability текст берём из исходного требования (или из
`docs/specs/` при backfill), сохраняя нормативную силу (SHALL/MUST) и сценарии.
Правки — только связочные (ссылки на соседние capability), не смысловые. Это
делает `openspec archive` безопасным: живые спеки после влития эквивалентны сумме
прежних.

## Risks / Trade-offs

- **Большой diff, риск потерять требование при переносе** → Перенос по таблицам
  D2–D5 (чек-лист: каждое исходное требование учтено — либо STAY, либо REMOVED+
  ADDED). `openspec validate --strict` + сверка счётчика требований до/после.
- **Расхождение docs/specs ↔ openspec после переезда** → В `docs/specs/`
  мигрированных файлов ставим шапку «источник истины переехал в
  `openspec/specs/<cap>`»; содержимое не дублируем.
- **Граница recognition ↔ metadata-match может «поплыть» на будущих задачах**
  (сила совпадения кандидата — идея беклога) → Сейчас фиксируем по действию;
  дальнейшее уточнение — отдельным change.
- **Пробел: у `ingest` нет формального требования на сам приём** (парс magnet →
  завести download → отдать в qBittorrent) — сейчас поведение не выражено
  требованием ни в одной спеке → см. Open Questions.

## Migration Plan

1. Дельты change: ADDED в новых/целевых спеках, REMOVED в исходных (D1).
2. `openspec validate --strict refactor-capability-boundaries`.
3. Ревью дизайна (этот документ) — **чекпоинт до написания всех дельт**.
4. После апрува — генерация дельта-спек, повторная валидация.
5. Пометки о переезде в `docs/specs/` мигрированных файлов; снять пункт беклога.
6. `openspec archive` — влить дельты в `openspec/specs/`.

Откат: change не тронул код; отмена = удалить директорию change (спеки не влиты до
archive).

## Resolved Questions

1. **Ядро приёма в `ingest` — ДА.** Добавляем ADDED «Приём источника и заведение
   загрузки» (backfill из `architecture.md` → «Транспорты»): парс magnet → дедуп →
   завести `download`+`download_infohash` → отдать в qBittorrent; ошибка добавления
   → `failed` (`qbit_add`). Иначе `ingest` остаётся про имя+инфохэши без ядра.
2. **«Секция раздачи на странице загрузки» — остаётся в `live-status`** (меняется
   вместе с телеметрией).
3. **Один change** (решение автора). При необходимости порядок дельт разложим на
   этапе apply.
