## Context

Ревью приёма 2026-07-08 (Fable) оставило четыре нити — N1, N3, N4, N5. Они не
связаны причиной, но связаны местом: все четыре живут на пути «источник →
загрузка → добавление в qBittorrent» и все четыре читаются одним заходом.
Изменение косметическое по последствиям и трогает пять пакетов, поэтому решения
о **месте** правки важнее самих правок.

Текущее состояние, проверенное по коду:

- `internal/torrent/torrent.go:72` — `DisplayName: meta.BestName()`.
  **Постановка задачи и первая редакция этого дизайна исходили из неверного
  факта** — будто библиотека сама подставляет `-` безымянной раздаче. Проверено
  по исходникам `anacrolix/torrent@v1.61.0`: `BestName()`
  (`metainfo/info.go:200-205`) возвращает `NameUtf8`, иначе `Name`, иначе пустую
  строку; `NoName = "-"` (`info.go:44`) присваивается только в
  `BuildFromFilePath` при **авторинге** раздачи из вырожденного пути. Значит
  `-` доходит до нас лишь тогда, когда раздача объявила его полем `name` сама
  (та самая конвенция «имени нет», ради совместимости с которой библиотека
  константу и экспортирует). Раздача **без** поля `name` даёт пустое имя, и этот
  случай код уже отрабатывает верно. Потребителей `DisplayName` три:
  `Info.Context()` (строка названия), `ingest.parse` (`source_ref`,
  единственный, кто про `-` знает) и `worker.sourceAddParts` (подсказка имени
  для `namer`, то есть вход LLM).
- `internal/ingest/ingest.go` — все выходы с ошибкой возвращают `Result{}`
  (строки 74–75, 86, 106–107 на момент ревью). Комментарии
  `internal/httpapi/httpapi.go:574` и `internal/tgbot/bot.go:256` описывают
  прежний контракт «сбой после создания задачи → непустой `DownloadID`».
- `internal/qbt/qbt.go:246` — ветка `Fails.` пишет `call.Failure` со счётчиками
  `urls`/`torrents`. Логгер клиент берёт из `ctx` (`logctx.FromOr`). Из двух
  вызовов `qbt.Add` фоновый (`worker.go:511`) идёт со scoped-логгером
  (`cctx`, `worker.go:415`), а `Retry` (`worker.go:1084`) — с сырым `ctx`
  транспорта: `Retry` не присваивает `ctx = w.scoped(…)`, в отличие от `Delete`
  (`review.go:619`), и трижды строит scoped-логгер одноразово прямо в аргументе
  записи. **Первая редакция этого дизайна считала `Retry` единственной такой
  командой — ревью изменения показало, что их семь** (см. «Что изменилось после
  ревью изменения»).
- `anacrolix/torrent v1.61.0`: `bencode/decode.go:17` задаёт
  `DefaultDecodeMaxStrLen = 1<<27-1`; `parseStringLength` (там же, `:211`)
  сверяет объявленную длину с этим потолком, а `parseString` (`:250`, `:258`)
  делает `make([]byte, length)` **до** чтения байтов. `metainfo.Load`
  (`metainfo/metainfo.go:35-37`) создаёт декодер без переопределения
  `MaxStrLen`, то есть с потолком по умолчанию.

## Goals / Non-Goals

**Goals:**

- Сентинел «без имени» не покидает пакет разбора — ни одному потребителю ниже
  по потоку не нужно про него знать.
- Комментарии транспортов описывают контракт `Ingest`, который есть, и контракт
  подтверждён тестом, а не только чтением.
- Запись о неуспешном добавлении в qBittorrent коррелируется с загрузкой на
  **обоих** путях добавления.
- Знание о пределе аллокации bencode записано с провенансом и замером.

**Non-Goals:**

- Не чиним поведение `anacrolix/torrent` — форк, `replace` и собственный
  разборщик bencode вне объёма и вне границы домена.
- Не добавляем клиенту `qbt` знание о доменных сущностях (инфохэш, id загрузки)
  — см. решение 3.
- Не трогаем `files()` и его фолбек на `meta.BestName()` для пути файла: это
  вырожденный случай (файл без пути **и** раздача без имени), к контексту
  распознавания отношения не имеющий.
- Не переписываем `Cancel`/`Dismiss` под ту же форму, что `Retry`: внешних
  вызовов у них нет, требование `identity` их не касается, правка была бы
  churn'ом (записано в «Открытые вопросы»).

## Decisions

### 1. N1 — нормализация имени стоит в `torrent.Parse`, а не в `Context()`

Задача формулировала фикс как «фильтровать `-` и в `Context()`». Это лечит
симптом: `DisplayName` остаётся заражённым, а знание о вырожденном имени
копируется в третье место. Потребителей у поля три, и третий
(`worker.sourceAddParts` → подсказка имени для LLM) в постановке не назван, хотя
страдает так же: `-` уезжает во вход вывода отображаемого имени.

Решение: нормализовать в `Parse` — `DisplayName` пуст, если разобранное имя
равно `metainfo.NoName`. Тогда `Context()` уже имеет проверку `name != ""` и не
меняется вовсе, `ingest.parse` теряет ветку `|| ref == "-"`, а подсказка имени
чинится без единой строки в `worker`.

Альтернатива «фильтровать в трёх местах» отвергнута: три копии знания об одном
вырожденном значении — ровно то, что архитектурный проход называет вторым
способом делать одно и то же. Альтернатива «оставить `-` и научить каждого
потребителя» хуже: потребители появляются, а значение одно.

Сравнение делаем с `metainfo.NoName`, а не со строковым литералом, по одной
причине — библиотека экспортирует константу **именно** затем, чтобы на неё
ссылались («By exposing it in the API we can check for references to this
behaviour», `metainfo/info.go:41-44`). Аргумент «сломается компиляция, а не
поведение» здесь **неверен** и снят: константа строковая, смена её значения
компиляцию не ломает.

Тем же местом закрывается и вторая грязь в строке названия: имя — недоверенный
вход, а контекст читается построчно, поэтому имя со встроенным переводом строки
добавляет в контекст строку, выглядящую как синтезированный нами факт.
Схлопывание разделителей строк уже применяется к комментарию торрента
(`oneLine`), и распространить его на имя — одна строка. **Инъекцией в промпт это
не управляет и управлять не может:** пользовательский текст контекста
многострочен по замыслу и идёт в промпт как есть; правило устраняет
несогласованность (комментарий схлопываем, имя нет), а не заводит защиту.

### 1а. Что изменилось после ревью предложения

Проход `specs` опроверг фактическое основание N1 оракулом — прогоном
крафт-входов через `metainfo.Load`. Из этого следует три правки, уже внесённые
выше и в дельта-спеку:

- формулировка требования говорит про **объявленное раздачей** вырожденное имя,
  а не про сентинел, подставляемый библиотекой;
- случай «раздача без поля `name`» назван отдельно и признан уже работающим —
  под него заводится сценарий и тест, потому что раньше он путался с первым
  (комментарий `internal/torrent/torrent_test.go:224` «info без имени
  (NoName-сентинел)» и есть источник исходной ошибки ревью 2026-07-08);
- критерий приёмки постановки «для безымянного торрента `Context()` не отдаёт
  `-`» выполняется **до** изменения. Это дефект критерия, а не задачи:
  проверяемое им поведение уже верно, а чинится соседнее. В докладе исход
  критерия назван честно, и заодно проверено то, что критерий имел в виду.

### 2. N3 — контракт объявляется в `ingest`, транспорты его не пересказывают

Комментарии в `httpapi` и `tgbot` разошлись с кодом потому, что каждый держал
**свою** копию контракта. Правка «переписать оба комментария» повторяет ту же
конструкцию и разойдётся снова.

Решение: контракт («на любом пути ошибки возвращается нулевой результат»)
объявляется один раз — в доке `ingest.Ingest` и `ingest.Result` — и
подтверждается тестом в пакете `ingest`. Транспорты получают короткий
комментарий-ссылку и передают в диагностику **пустой** идентификатор, а не
заведомо пустой `res.DownloadID`: значение, которое доказуемо всегда пусто,
прочитанное как «а вдруг непусто», и породило исходную нить.

Альтернатива «оставить `res.DownloadID`, поправить только текст» отвергнута:
код продолжит утверждать обратное тому, что говорит комментарий.

**Контракт держится структурой, а не перечнем веток** (найдено проходом
`rubric`): `Ingest` получает именованный возврат и один `defer`, обнуляющий
результат при ненулевой ошибке. Иначе лекарство повторяет болезнь — сегодня три
ветки возврата аккуратны, а завтра четвёртая вернёт полузаполненный результат,
и тест, перечисляющий три известных класса отказа, этого не заметит. Тест
сравнивает результат с нулевым значением **целиком**, а не по полю
`DownloadID`.

Вызовов приёма на транспортах **три**, а не два (найдено проходом `specs`):
REST `handleAPIAdd`, веб-форма `handleUIAdd` и `tgbot.ingestAndReply`. Третий в
первой редакции задач отсутствовал — добавлен, иначе требование «транспорты не
обещают идентификатора, которого нет» оказалось бы выполненным на две трети.

Корреляционный ключ у отказа приёма **разный по транспортам**, и требование
называет это прямо: HTTP и веб-UI дают `request_id`, Telegram не даёт ничего
(`opErr` при пустом идентификаторе возвращает текст без ключа). Это не
регрессия — сегодня `res.DownloadID` уже всегда пуст, и наблюдаемое поведение
Telegram не меняется. Но пробел реален, и заводить ли Telegram-транспорту
собственный ключ — записанный вопрос, а не молчаливое «потом».

### 3. N4 — корреляция приходит из `ctx`, полей клиенту не добавляем

Задача предлагала «логировать хеши/первые байты». Клиент `qbt` инфохэша не
знает: на magnet-ветке у него ссылка, на torrent-ветке — байты, и вычислить хеш
он мог бы только собственным разбором источника, а разбор источника — единая
точка проекта (`internal/magnet`, `internal/torrent`). Поле `Infohash` в
`AddRequest` завело бы второй канал корреляции рядом со scoped-логгером и жило
бы только ради лога.

Настоящая причина того, что оператор не находит запись: `Retry` не кладёт
scoped-логгер загрузки в `ctx`. Фоновый путь добавления это делает и запись
`Fails.` там уже несёт `download_id` и `infohash`; путь retry — нет. То есть N4
— не «клиент мало пишет», а «один вызывающий не выполнил обязанность, которую
выполняют остальные».

Решение: `Retry` присваивает `ctx = w.scoped(ctx, capReview, id,
d.PrimaryInfohash())` сразу после чтения загрузки, а одноразовые `w.scoped(…)`
и ручные `w.log.…` внутри него схлопываются в `logctx.From(ctx)`. Обязанность
вызывающего закрепляется требованием в `identity` — **ограниченным вызовом
внешнего сервиса**, а не «первым вызовом, способным что-либо записать» (найдено
проходами `architecture` и `specs`): в широкой формулировке требование объявляло
бы нарушителями `Cancel` и `Dismiss`, которые этот же change сознательно не
трогает, и нормативная спека стала бы ложной в момент архивации.

Побочный эффект присвоения назван заранее, чтобы на чекпоинте 2 он не читался
регрессией: поля `capability`/`download_id`/`infohash` появятся также у
`qbt.Torrents` из `torrentByInfohash`, у `logCmd` и у предупреждений внутри
метода. Это и есть цель. Двойной `download_id` в `logCmd` (из логгера и явным
аргументом) — существующее поведение пути `Delete`, прецедент есть, чистить его
здесь не будем.

Тест записи о `Fails.` проверяет не только наличие полей, но и **отсутствие
лишнего**: значение отправленного magnet с узнаваемым `passkey` в записи не
встречается ни в одном поле (найдено проходом `rubric`). Инвариант «секреты не
попадают в логи» — `major`, а `add` — единственный вызов `qbt`, через который
magnet-URI приватного трекера физически проходит.

«Первые байты источника» в лог не идут: для magnet это часть URI (шум, а на
приватном трекере — потенциально чувствительный passkey), для torrent — начало
bencode, диагностической ценности не несущее.

Причину отказа qBittorrent мы при этом не получаем и не выдумываем: `Fails.`
причины не несёт, и никакого «это был дубль» из него не выводится. Запись честно
остаётся «отказ без причины», но становится привязанной к загрузке.

### 4. N5 — исход пункта запись, а не код

Пункт про аллокации bencode правке не подлежит: это чужая библиотека, поведение
ограничено (потолок ~128 MiB) и завершается ошибкой, а не порчей данных.
Записываем наблюдение в `docs/research/` с провенансом (версия, файл:строка) и
**замером**, снятым воспроизводимой командой во временном каталоге, а не
оценкой из головы. Требование каталога разведки — «каждый вывод с числами и
командой, которой получен» — выполняется буквально.

Порог, ниже которого пункт стал бы дефектом: если бы аллокация не была
ограничена или не завершалась ошибкой. Это проверяется замером, а не
рассуждением, — потому замер и делается.

Записка получает **условие устаревания** («перепроверить при обновлении
`anacrolix/torrent`»): наблюдение о коде зависимости протухает от бампа
`go.mod`, в отличие от наблюдения о формате провода, которое живёт своей жизнью.
Вводная `docs/research/README.md` расширяется на полстроки, чтобы каталог честно
включал и такие наблюдения (найдено проходом `architecture`). Туда же уезжает
второе наблюдение о той же библиотеке — про `BestName`/`NoName`: цена нити N1
ровно в том, что этого наблюдения не было записано нигде.

## Risks / Trade-offs

- **Нормализация `DisplayName` меняет вход `namer` для безымянных раздач** →
  раньше в подсказку уходил `-`, теперь пусто. Это улучшение (вход LLM чище), но
  формально смена поведения. Митигация: путь покрыт тестом приёма на безымянной
  раздаче; `namer` пустую подсказку и так обрабатывает (magnet без `dn` даёт то
  же самое).
- **Scoped-логгер в `Retry` добавляет поля к записям, которых там раньше не
  было** → тесты, сверяющие записи журнала на пути retry, могут разойтись.
  Митигация: гейт (`test`, `flaky`) ловит это детерминированно.
- **Замер аллокаций bencode делается на синтетическом входе** → он показывает
  поведение декодера, а не профиль реального `.torrent`. Митигация: в записке
  прямо сказано, что число получено на крафт-входе, и приведена команда.
- **Пустой идентификатор в диагностике транспорта** → если контракт `Ingest`
  когда-нибудь начнёт возвращать id на ошибке, транспорты его не покажут.
  Митигация: контракт закреплён спекой и тестом, менять его придётся осознанно
  и вместе с транспортами.

## Migration Plan

Миграции нет: схема БД, конфигурация, HTTP-контракт и тексты Telegram не
меняются. Нумерованных артефактов изменение не добавляет. Откат — обычный
revert коммита.

## Что изменилось после ревью изменения (чекпоинт 2)

Профиль `deep`, семь проходов. Отработано инлайн:

- **Порядок в `displayName` был неверен** — сравнение с сентинелом стояло до
  схлопывания, и имя `" - "` превращалось в `-` уже после проверки. Три прохода
  (`specs`, `adversary`, `reimpl`) нашли это независимо, два принесли падающий
  тест; `adversary` показал, что это **регрессия против `master`**, где фолбек
  `source_ref` на имя файла срабатывал. Порядок исправлен и закреплён в спеке.
- **Команд без scoped-логгера оказалось семь, а не одна.** Кроме `Retry`,
  внешний сервис до постановки логгера зовут `Relink`, `Rerecognize`, `Refine`
  (qBittorrent через `ensureSourceReady`) и `ChooseCandidate`,
  `AddManualSource`, `SetProviderID` (метабаза через `recognizer.Director`).
  Развилка разрешена в пользу починки всех семи: узкая альтернатива требовала
  сузить требование `identity` до одного пути, и тогда нормативная спека
  фиксировала бы слепоту как норму. Конструкция, снимающая класс целиком
  (обёртка входа команд), — записанный вопрос ниже.
- Добавлены недостающие оракулы: отказ приёма на обоих HTTP-транспортах
  коррелируется по `request_id`; best-effort ветки `Retry` исполняются тестами.
- Записка разведки получила текст программы замера и **разграничение
  `TotalAlloc` против RSS**: замер `ops` показал, что физическая память при
  деградационном пути не расходуется вовсе, и вывод записки от этого становится
  сильнее, а не слабее.

## Open Questions

- **Заводить ли обёртку входа команд воркера, делающую пропуск scoped-логгера
  невозможным?** Пролог всех ~18 публичных команд однороден: `defer logCmd` →
  `w.mu.Lock` → чтение загрузки → проверка состояния → `ctx = w.scoped(…)`.
  Последний шаг держится дисциплиной, и дисциплина уже дала семь промахов из
  ~дюжины — то есть это не гипотеза, а измеренная частота. Варианты:
  (а) обёртка `w.withDownload(ctx, op, capability, id, allowedStates, fn)` —
  пропуск невозможен по построению, цена: правка всех команд разом и потеря
  гибкости там, где capability меняется по ходу метода (`review.go` дважды
  пере-скопливает под `capFileLayout`); (б) тест-перебор публичных команд,
  краснеющий на команде без scope, — дешевле и не трогает продовый код, но
  ловит только известную форму нарушения; (в) оставить дисциплину. Пока
  решения нет, стоит (в) — все известные нарушения починены. Рекомендация —
  (б) отдельной задачей: механизация без переписывания, ровно по процедуре
  промоута «находка → конвенция → правило».
- **Нужен ли Telegram-транспорту собственный корреляционный ключ у отказа
  приёма?** Сегодня `opErr` при пустом идентификаторе возвращает текст без
  ключа, а конвенция (`docs/conventions/errors.md`, «Граница и трансляция»)
  требует к сообщению **корреляционный ключ** — `download_id` либо
  `request_id`. На пути приёма из Telegram нет ни того, ни другого, и это
  главный сценарий паспорта. Варианты: (а) завести идентификатор запроса на
  входе обработчика апдейта (или взять `update_id`) и класть его в scoped-логгер
  и в текст отказа — цена в том, что рядом с существующей корреляцией по
  `download_id`/`infohash` появляется второй канал, ровно тот класс, который
  решение 3 отвергло для `qbt`; (б) не заводить ключ, а поднять запись отказа
  разбора с `DEBUG` до `INFO` с `infohash`, и тогда искать по инфохэшу — дешевле,
  но у пользователя в руках всё равно ничего нет; (в) оставить как есть.
  Пока решения нет, стоит (в): изменение поведения Telegram не меняет и
  регрессии не вносит. Рекомендация — (б) отдельной задачей: она закрывает
  наблюдаемость, не заводя второго канала корреляции. Пробел существует до этого
  изменения и им не создан.
- **Приводить ли `Cancel` и `Dismiss` к той же форме, что `Retry` и `Delete`
  (присвоение `ctx = w.scoped(…)` в начале команды вместо одноразового
  построения логгера в аргументе записи)?** Варианты: (а) привести — правило
  «команда воркера скопливает `ctx` один раз» становится однородным, цена —
  правка двух методов без наблюдаемого эффекта, риск разойтись с тестами
  журнала; (б) не трогать — у этих команд нет внешних вызовов, поэтому
  наблюдаемой разницы нет, цена — форма остаётся неоднородной, и следующий
  вызывающий скопирует ту из двух, что попалась. Пока решения нет, стоит (б):
  изменение закрывает N4 и не расширяется. Рекомендация — (а) отдельной
  задачей-гигиеной, когда в `Cancel`/`Dismiss` появится первый внешний вызов;
  раньше этого момента правка не окупается.
