ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
@@ -0,0 +1,125 @@
## ADDED Requirements
### Requirement: Вырожденное имя раздачи не считается именем
Система SHALL нормализовать имя раздачи из `.torrent` **на границе разбора
источника** (`internal/torrent`) и не считать содержательным именем вырожденное
значение `-` — тот же литерал, которым экосистема обозначает «имени нет»
(в библиотеке разбора он выставлен константой `metainfo.NoName` и присваивается
при авторинге раздачи из вырожденного пути). Раздача объявляет это значение
**сама**, полем `name`; библиотека его не синтезирует, поэтому речь о разборе
объявленного значения, а не о фильтре чужого сентинела.
Раздача, у которой поля `name` нет вовсе, уже даёт пустое имя — это отдельный
случай, и он нормализации не требует. Оба случая после разбора SHALL быть
неразличимы: имени нет.
Нормализация SHALL применяться к **имени раздачи** и охватывать три поля,
которые из него выводятся:
- строка названия в синтезированном контексте распознавания (см. «Приём
источника из .torrent-файла») — за неимением имени строка названия
отсутствует;
- `source_ref` загрузки — работает прежний фолбек на имя присланного файла;
- подсказка вывода отображаемого имени, которую воркер берёт из метаданных
торрента (см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent») — подсказка остаётся пустой.
Пути файлов раздачи нормализации **не** подлежат, и это осознанно. Вырожденное
имя попасть в путь файла может — при пустом наборе сегментов путь берётся
фолбеком из имени раздачи, ненормализованного. Допустимо потому, что путь файла
читается только как число файлов и набор расширений (сигнал по дереву), а
целевые пути раскладки строятся не из него, а из ответа qBittorrent
(см. `file-layout`). Появится потребитель, читающий путь как путь, —
нормализовать его надо там же, на границе разбора.
Имя раздачи — недоверенный вход, а контекст распознавания читается
**построчно**. Поэтому нормализация SHALL схлопывать в один пробел любые
последовательности пробельных символов — включая разделители строк — и убирать
краевые, чтобы имя не могло добавить в контекст строку, выглядящую как
синтезированный нами факт. Схлопывание SHALL выполняться **до** сравнения с
вырожденным значением, иначе имя вида `" - "` сравнение не пройдёт, а после
схлопывания станет ровно вырожденным и уедет вниз по потоку. Тем же правилом
уже обрабатывается комментарий торрента. Защитой от инъекции в промпт оно
**не** является и таковой объявляться SHALL NOT: пользовательский текст
контекста многострочен по замыслу и идёт в промпт как есть (см. «Синтез
контекста распознавания из полей magnet»).
Правило **не** однородно с фильтром заглушек-идентификаторов `dn` у magnet: там
заглушка вида `*-topic-<id>` отбрасывается только в контексте, а в подсказку
вывода имени по-прежнему уходит (действующее требование «Синтез контекста
распознавания из полей magnet»). Здесь правило строже, и распространение его на
magnet — отдельное изменение, этим требованием оно не заказано.
#### Scenario: Раздача объявила имя `-`
- **GIVEN** `.torrent`, у которого поле `name` равно `-` либо становится равным
`-` после схлопывания пробельного (например `" - "`)
- **WHEN** система разбирает источник и синтезирует контекст распознавания
- **THEN** имя раздачи после разбора пусто, строки названия в контексте нет
- **AND** остальные строки-факты (размер, файлы, трекер, комментарий)
синтезируются как обычно
- **AND** подсказка вывода отображаемого имени пуста
#### Scenario: Раздача без поля `name`
- **GIVEN** `.torrent` без поля `name`
- **WHEN** система разбирает источник
- **THEN** имя раздачи пусто — так же, как у раздачи, объявившей `-`
#### Scenario: Вырожденное имя даёт source_ref из имени файла
- **GIVEN** `.torrent` с именем `-`, присланный файлом `Dune.torrent`
- **WHEN** вызывается приём
- **THEN** `source_ref` загрузки — `Dune.torrent`, а не `-`
#### Scenario: Имя с переводом строки не добавляет строк в контекст
- **GIVEN** `.torrent`, имя которого содержит перевод строки и текст, похожий на
синтезированный факт
- **WHEN** система синтезирует контекст распознавания
- **THEN** имя занимает ровно одну строку названия
- **AND** число строк-фактов в контексте такое же, как у раздачи с обычным именем
### Requirement: Результат приёма при ошибке пуст
Приём SHALL возвращать транспорту **нулевой результат** на любом пути ошибки:
идентификатор загрузки, инфохэши, состояние и признак дедупликации в этом случае
не публикуются. Причина в том, что быстрый приём не создаёт наблюдаемых
последствий раньше, чем становится способен вернуть успех: разбор источника,
дедуп-чек и заведение загрузки идут до ответа, а всё, что может отказать после
заведения (добавление в qBittorrent, вывод имени), выполняет воркер и на исход
приёма не влияет.
Контракт SHALL удерживаться **структурно** — одним местом обнуления результата
при ненулевой ошибке, а не аккуратностью каждой ветки возврата: перечень веток
растёт, и именно расхождение перечня с текстом породило исходный дефект.
Транспорты SHALL опираться на этот контракт и SHALL NOT обещать пользователю или
журналу идентификатор загрузки, которого нет. Корреляционный ключ публичного
отказа приёма зависит от транспорта, и требование называет его поимённо:
- **HTTP API и веб-UI** — `request_id` запроса (см. `docs/conventions/errors.md`,
«Граница и трансляция»);
- **Telegram** — корреляционного ключа у отказа приёма нет, и требование это
фиксирует как сегодняшнее состояние, а не как цель: диагностика ищется по
записи приёма (`capability=ingest`, `infohash`). Заводить Telegram-транспорту
собственный идентификатор запроса это требование SHALL NOT.
#### Scenario: Невалидный источник
- **WHEN** приём получает источник, который не разбирается ни как magnet, ни
как `.torrent`
- **THEN** возвращается ошибка и нулевой результат (идентификатор загрузки пуст)
#### Scenario: Сбой хранилища на заведении загрузки
- **WHEN** приём получает валидный источник, но хранилище отказывает при
дедуп-чеке или заведении загрузки
- **THEN** возвращается ошибка и нулевой результат
#### Scenario: Отказ приёма на HTTP-границе
- **GIVEN** приём отказал по любой причине
- **WHEN** HTTP-транспорт (REST или веб-форма) отвечает пользователю
- **THEN** ответ несёт `request_id` запроса, а не идентификатор загрузки