## 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/`»; содержимое не дублируем. - **Граница 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.