## 1. N1 — вырожденное имя не покидает разборщик

- [x] 1.1 В `internal/torrent/torrent.go` нормализовать `DisplayName` в `Parse`:
      сравнение с `metainfo.NoName` даёт пустую строку; краевые пробелы и
      разделители строк схлопываются тем же `oneLine`, что уже применяется к
      комментарию. В доке `Info.DisplayName` сказать, что нормализация здесь и
      ниже по потоку вырожденное значение не встречается.
- [x] 1.2 В `internal/ingest/ingest.go` убрать ветку `|| ref == "-"` из
      фолбека `source_ref` и поправить комментарий (нормализовано разборщиком).
- [x] 1.3 Тесты в `internal/torrent`, три входа врозь: (а) `name` равно `-` →
      `DisplayName` пуст и `Context()` без строки названия; (б) `name`
      отсутствует → `DisplayName` пуст (случай уже работал, закрепляем, чтобы не
      путался с (а)); (в) имя с переводом строки → одна строка названия, число
      строк-фактов как у обычного имени. Заодно исправить неверный комментарий
      `TestContextEmptyWhenNoFields` («info без имени (NoName-сентинел)») — он и
      есть источник исходной ошибки ревью.
- [x] 1.4 Тест в `internal/ingest`: `.torrent` с именем `-`, присланный файлом →
      `source_ref` равен имени файла.
- [x] 1.5 Тест в `internal/worker`: для раздачи с именем `-` подсказка,
      уходящая в `namer`, пуста (третий потребитель `DisplayName` — вход LLM;
      `fakeNamer` дополнить полем `gotHint`).

## 2. N3 — контракт «на ошибке результат пуст»

- [x] 2.1 В `internal/ingest/ingest.go` объявить контракт в доке `Ingest` и
      `Result` и удержать его **структурно**: именованный возврат + один
      `defer`, обнуляющий результат при ненулевой ошибке.
- [x] 2.2 Тест в `internal/ingest`: на каждом классе отказа (невалидный
      источник, сбой хранилища на дедуп-чеке, сбой хранилища на заведении)
      результат равен нулевому значению **целиком**, а не только по полю
      `DownloadID`.
- [x] 2.3 `internal/httpapi/httpapi.go`, REST `handleAPIAdd` — заменить
      устаревший комментарий и передавать в `s.apiErr` пустой идентификатор.
- [x] 2.4 `internal/httpapi/httpapi.go`, веб-форма `handleUIAdd` — то же для
      `userErr` (третий вызов приёма, в первой редакции задач пропущен).
- [x] 2.5 `internal/tgbot/bot.go` — то же для `opErr`; в комментарии назвать
      честно, что корреляционного ключа у отказа приёма в Telegram нет.

## 3. N4 — корреляция записи о неуспешном добавлении

- [x] 3.1 В `internal/worker/worker.go` метод `Retry` присваивает
      `ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())` сразу после
      чтения загрузки; одноразовые `w.scoped(…)` и ручные `w.log.…` с
      доклеенными `capability`/`download_id` внутри метода схлопнуть в
      `logctx.From(ctx)`.
- [x] 3.2 Тест в `internal/qbt` с подставным сервером, отвечающим `Fails.`:
      запись о вызове содержит поля из scoped-логгера `ctx` (`download_id`,
      `infohash`) и `ext.operation = torrents/add`; **негативная половина** —
      значение отправленного magnet с узнаваемым `passkey` в записи не
      встречается ни в одном поле, включая `error`.
- [x] 3.3 Тест в `internal/worker`: `Retry`, дошедший до `Add`, вызывает клиента
      с `ctx`, несущим scoped-логгер загрузки (`fakeQbt` дополнить записью
      переданного `ctx`).

## 4. N5 — записка о границах разбора `.torrent`

- [x] 4.1 Снять замер во временном каталоге (`tmp/`, после замера удаляется):
      крафт-вход, объявляющий длинную bencode-строку, через `metainfo.Load`;
      зафиксировать аллокацию и исход для нескольких объявленных длин, включая
      значение выше потолка.
- [x] 4.2 Завести `docs/research/torrent-bencode-limits.md`: версия библиотеки,
      файл:строка, команда замера, числа, вывод с условиями; наблюдение о
      `BestName`/`NoName` (что библиотека сентинел не синтезирует); отметить,
      что panic-гард `files()` не покрывает `Load`/`UnmarshalInfo`/`HashBytes`,
      и назвать **условие устаревания** — перепроверить при обновлении
      `anacrolix/torrent`.
- [x] 4.3 Добавить строку записки в индекс `docs/research/README.md` и
      расширить его вводную: каталог держит и наблюдения о поведении наших
      разборщиков и зависимостей на границе формата.

## 5. Отработка ревью изменения (чекпоинт 2, профиль `deep`)

- [x] 5.1 B1 — в `torrent.displayName` схлопывание идёт ДО сравнения с
      `metainfo.NoName`; тест стал табличным (`-`, `" - "`, `"-\n"`, `"\t-"`,
      NBSP+`-`); порядок закреплён в дельта-спеке.
- [x] 5.2 B2 — scoped-логгер до внешнего вызова добавлен ещё в шести командах
      (`Relink`, `Rerecognize`, `Refine`, `ChooseCandidate`, `AddManualSource`,
      `SetProviderID`); ложное «единственная команда» в `proposal.md`
      исправлено.
- [x] 5.3 F1 — тест корреляции по `request_id` на обоих HTTP-транспортах
      (REST и веб-форма).
- [x] 5.4 F2 — инъекция ошибок в `fakeStore` (`SetDownloadState`,
      `SetRetriedAt`, `SetSourceMissCount`) и тесты best-effort веток `Retry`.
- [x] 5.5 F3 — в записку разведки вложен текст программы замера и добавлено
      разграничение `TotalAlloc`/RSS с числами.
- [x] 5.6 F4 — в дельта-спеке исправлены два неточных утверждения (широта
      схлопывания и обоснование исключения для путей файлов).

## 6. Проверка

- [x] 6.1 `task gate` зелёный (go-шаги отработали, не `SKIP`).
- [x] 6.2 `openspec validate --strict ingest-nits`.

## Приёмочные критерии

### Из постановки задачи

Копия из `docs/tasks/items/ingest-nits.md` — приходят снаружи, пайплайном не
сочиняются и не занижаются.

- Для безымянного торрента `Context()` не отдаёт «-» как название — поле пустое
  (оракул: тест разбора на фикстуре безымянного торрента в `internal/torrent`).
  **Замечание к критерию:** ревью предложения показало, что для торрента *без
  имени* это верно и до изменения; отдаёт «-» торрент, который сам объявил
  `name: "-"`. Проверяются оба входа врозь (задача 1.3).
- Комментарии в `httpapi` и `tgbot` описывают фактическое поведение `Ingest`:
  на любом пути ошибки возвращается пустой `Result`, корреляция идёт по
  `request_id` (оракул: чтение диффа на ревью — механического оракула нет).
- Лог неудачного добавления в qBittorrent несёт инфохэш для корреляции (оракул:
  тест клиента с подставным сервером, проверяющий поля записи).
- Наблюдение про аллокации bencode до `MaxStrLen` записано в `docs/research/`
  с провенансом либо явно отклонено строкой в теле задачи (оракул: `task gate`,
  шаг канона).

### Из рубрики прохода `review-rubric`

Свойства, порождённые до чтения кода; взяты те, что изменение обязано
удовлетворить. Непокрытые названы явно.

- Нормализация вырожденного имени живёт **ровно в одной точке** — на границе
  разбора; ни один потребитель `DisplayName` знания о `-` не содержит (оракул:
  1.1–1.2 плюс `grep -rn 'NoName\|"-"' internal --glob '!internal/torrent/**'`
  без попаданий по смыслу «имя раздачи»).
- Нормализация имени охватывает разделители строк и краевые пробелы, а не
  только вырожденное значение (оракул: 1.3в).
- У **каждого** из трёх потребителей нормализованного поля свой оракул (оракул:
  1.3, 1.4, 1.5 — три теста в трёх пакетах).
- Контракт «на ошибке результат нулевой» удерживается структурой, а не
  перечнем известных путей (оракул: 2.1 — одно место обнуления; 2.2 — сравнение
  с нулевым значением целиком).
- Публичная диагностика отказа приёма несёт корреляционный ключ, и ключ назван
  поимённо для каждого транспорта (оракул: требование дельта-спеки; для Telegram
  зафиксировано отсутствие ключа как сегодняшнее состояние — вопрос записан).
- Запись об отказе внешнего сервиса самодостаточна и **не несёт секретов**
  (оракул: 3.2, обе половины).
- **Не покрыто и почему:** свойство «обязанность класть scoped-логгер проверена
  механически перебором всех команд воркера» — перебор потребовал бы правки
  `Cancel`/`Dismiss`, которую этот change сознательно не делает (открытый вопрос
  дизайна); оракул остаётся точечным (3.3). Свойство «поле `infohash` записи
  называет тот источник, с которым вызов ушёл» — не покрыто: сегодня значения
  совпадают, расхождение возможно лишь у загрузки с несколькими хешами; идёт в
  урожай.
