# Ревью: настройка и журнал

Проектная часть конвейера ревью: чем jellybit отличается от абстрактного
Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (метки,
стадии, контракт находок) живёт в скилле, а не здесь.

## Как настроен конвейер

**Прежде чем задать вопрос, посмотри, не задан ли он уже машиной.** Перечень
механизированного — [conventions/README.md](conventions/README.md) →
«Механизировано»; чем безусловно краснеет гейт и чего в нём намеренно нет —
[CLAUDE.md](../CLAUDE.md) → «Гейт». Вопрос про уже проверенное вытесняет вопрос
про непроверенное — места в прогоне столько же.

**В worktree гейт краснеет ложно, и это не находка.** Ветки задач живут в
`tmp/wt-<задача>` — внутри самого репозитория. Два следствия, оба наблюдались:

- кэш `golangci-lint` переживает смену каталога и отдаёт результаты прошлого
  прогона из **основного** дерева. Признак — пути в `tmp/gate/lint.log`
  начинаются с `../../internal/`, то есть указывают наружу worktree, и жалобы
  приходят на файлы, которых дифф не касался. Лечится
  `golangci-lint cache clean` перед прогоном;
- пробы проходов ревью, оставленные в `tmp/`, линтуются вместе с проектом:
  `.go`-файл со `fmt.Printf` в `tmp/` краснит шаг `lint` через `forbidigo`.
  Проход обязан за собой убирать, а оркестратор — сверять `tmp/` перед гейтом.

**Severity не выводится проходом заново.** Она стоит рядом с формулировкой
инварианта в [CLAUDE.md](../CLAUDE.md) → «Инварианты», обратимость — там же в
«Работа» → «Необратимое». Шкала ущерба берётся оттуда, а порядок ценностей —
из [security.md](security.md) → «Что чувствительнее чего».

### Типовые узлы

Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь
пакетов: узел, которого ещё нет, но который проект заведёт, включён намеренно.

**Клиент внешнего HTTP-сервиса** (`qbt`, `llm`, `metadata`, `jellyfin`, `tgbot`)

- у каждого исходящего вызова свой таймаут из конфига, не дефолт транспорта;
- `context` доходит до запроса и отменяет его, а не игнорируется;
- ошибка зависимости отличима от ошибки нашей логики на приёме результата;
- секреты (пароль, ключ, токен) не попадают ни в лог, ни в текст ошибки;
- недоступность **опциональной** зависимости (метабаза, Jellyfin, Telegram) не
  двигает состояние загрузки и не краснеет ERROR-ом в фоновом цикле.

**Тик воркера и переход состояния**

- переход легален по декларативному графу, а не «просто присвоили `state`»;
- работа идёт под per-download блокировкой; два транспорта не гонятся;
- тик идемпотентен: повтор на том же состоянии не порождает второго эффекта;
- отмена контекста на середине не оставляет полуприменённого состояния;
- новое промежуточное состояние имеет выход **и** предохранитель по времени.

**Репозиторий `store`**

- запрос параметризован, время только через `store.Now()`, id через `ident`;
- многошаговое изменение — в одной write-транзакции (`_txlock=immediate`);
- инвариант, не выражаемый схемой («одна активная загрузка на infohash»),
  держится guarded-методом, а не проверкой в вызывающем коде;
- миграция forward-only и сопровождается правкой [database.md](database.md).

**Операция с файловой системой** (`layout`)

- целевой путь проверяется **после** `filepath.Clean`, на принадлежность
  библиотеке;
- существующая цель не перезаписывается ни при каком исходе;
- под `paths.downloads` нет ни одной операции записи или удаления;
- частичный сбой батча оставляет систему в состоянии, из которого повтор
  доводит начатое или откатывает целиком;
- удаление снимает только свои ссылки своего батча и не снимает последнюю копию.

**Парсер недоверенного входа** (`magnet`, `torrent`, парсер сообщения бота,
разбор ответа LLM)

- вход враждебный по умолчанию: длина, вложенность, мусорные байты, пустота;
- разбор не паникует и не аллоцирует по числу из самого входа;
- невалидный вход даёт доменную ошибку, а не тихий дефолт;
- результат нормализуется на границе (lowercase hex, trim, `ident.Parse`).

**Вызов LLM и разбор его ответа** (`recognize`, `naming.Derive`)

Самый специфичный род узла в проекте: единственный, чей выход недетерминирован,
недоверен и стоит денег одновременно.

- решение узла **не является гейтом безопасности**: инъекция в промпт считается
  состоявшейся, защита стоит ниже — на валидации целевого пути
  ([security.md](security.md));
- самооценка модели не заменяет матч в метабазе — порог `confidence` стоит
  **поверх** матча дополнительным условием, а не вместо него;
- разбор ответа устойчив к лишним и недостающим полям, обрезке и не-JSON;
  негодный ответ даёт доменную ошибку, а не тихий дефолт;
- попытка стоит лимита и денег: число ретраев берётся из конфига, а фоновый цикл
  не запускает распознавание сам по себе повторно;
- тест не ходит в живой эндпоинт — провайдер за интерфейсом, ответ фикстурой;
  живые прогоны только за env-гейтом.

**htmx-хендлер**

- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
- ошибка на htmx-пути — 200 плюс фрагмент, а не 4xx/5xx;
- страница деградирует без JS;
- поллинг самозавершается, когда наблюдать больше нечего.

### Типовые ложноположительные

Находки, которые здесь выглядят убедительно и всегда неверны.

- **«Веб-UI и REST без авторизации».** Принятое решение под сегодняшний
  периметр — [security.md](security.md). Дефектом станет только вместе с путём
  снаружи LAN.
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
  [conventions/web-ui.md](conventions/web-ui.md).
- **«Решение auto/review должно опираться на `confidence` модели».** Неверно в
  форме «вместо матча»: авто только при подтверждённом матче в базе —
  [ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
  Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог `confidence`
  при этом существует **дополнительным** блокирующим условием поверх матча —
  его наличие дефектом не является.
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
  осознанный выбор ради неприкосновенности источника и недублирования диска,
  [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
  фолбэк.
- **«Целочисленный автоинкрементный ключ был бы проще».** ULID — требование
  capability `identity`; `AUTOINCREMENT` вдобавок краснит гейт.
- **«Не хватает метрик, трейсинга, health-эндпоинтов по каждой зависимости».**
  «Минимум компонентов» — принцип проекта; глубокий healthcheck заведён задачей
  и ждёт своей очереди, а не является упущением.
- **«Здесь нужен интерфейс, чтобы это можно было замокать».** Единственная
  реализация за интерфейсом — обычно лишний слой; см. «Честный предел» ниже.
- **«Нет ретрая у вызова в фоновом цикле».** Тик повторится сам через
  `poll_interval`; ретрай внутри тика чаще вреден.
- **«Оригинальное и локализованное названия дублируются — избыточность».**
  `original_title` заполняется всегда и при неуверенности дублирует `title` —
  это контракт capability `recognition`, а не недосмотр.

### Вопросы по темам

Форма: `<тема>: <вопрос> (<провенанс>)`. Адресуется теме, а не имени прохода:
проход переезжает между метками и упраздняется, тема переезд переживает. Задаёт
вопрос тот, кто закрывает тему на текущем прогоне.

- `security`: можно ли, управляя только именами файлов в раздаче и текстом
  контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
  через юникод, длину сверх лимита ФС и коллизию после нормализации?
  (инвариант «целевой путь строго под библиотекой», [security.md](security.md))
- `security`: есть ли последовательность команд, после которой снимается
  **последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
  (инвариант «источник неприкосновенен»)
- `security`: что даёт крафт-магнет с чужим или подставным инфохэшем —
  присоединение к чужой активной загрузке, отравление владения?
  (открытая задача про идентичность инфохэшей)
- `security`: где признак «это наше» снимается с одной сущности, а действие
  применяется к другой — присутствие раздачи в qBittorrent против байтов на
  диске, запись в БД против файла, инфохэш против содержимого? (журнал,
  2026-08-06: уборка своего торрента сносила чужие файлы)
- `operations`: что делает эта ветка, когда qBittorrent недоступен несколько
  минут подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
  (задача про ERROR-шторм фоновых циклов)
- `operations`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
  `file_link` — есть ли запрос без индекса и полный проход по таблице?
  (задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
- `operations`: что остаётся на диске и в базе, если процесс убит посреди
  раскладки батча? (состояние `linking` и его восстановление)
- `autotests`: изменённые строки не просто исполнены тестом, а **проверены** —
  есть ли тест, который упал бы без этой правки? (diff-coverage в гейте меряет
  исполнение, отличить его от проверки машина не может)
- `autotests`: конкурентная часть правки проверена тестом, а не рассуждением?
  (`-race` без gcc уходит в `SKIP`, и тогда гонки не проверял никто —
  [CLAUDE.md](../CLAUDE.md) → «Гейт»)
- `autotests`: новый тест не ходит в живой qBittorrent, LLM, метабазу или
  Telegram — а если ходит, он за env-гейтом в `*_integration_test.go`?
  ([CLAUDE.md](../CLAUDE.md) → «Запреты»)
- `conventions`: логирующий чекпоинт один на операцию — или ошибка залогирована
  и возвращена вверх, где залогирована снова?
  ([conventions/logging.md](conventions/logging.md))
- `conventions`: новое поле конфига появилось в `config.example.toml` с
  описанием назначения, диапазона и единиц?
  ([conventions/config.md](conventions/config.md))
- `requirements`: не завелось ли поведение, которого спека не заказывала — тихий
  дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
- `security`: читается ли тело ответа внешнего сервиса целиком без предела —
  у LLM (8 MiB) и метабаз (4 MiB) предел стоит, и новый исходящий вызов обязан
  заводить свой ([security.md](security.md) → «Что вне модели»)
- `operations`: гарантия, которую вводит изменение, поставлена на запись или на
  чтение — и что будет с данными, записанными до деплоя, которые обычный путь
  не перезаписывает? (журнал, 2026-08-10: чистка названия стояла на записи, и
  очередь ревью её обходила)
- `operations`: новая проверка встала в общую точку — что она отняла у тех, кто
  зовёт эту точку не ради проверки? Пропала ли команда, стал ли предпросмотр пустым, перестал ли экран объяснять
  причину, и узнает ли человек причину на **каждом** входе, а не только
  на том, который разбирали? (журнал, 2026-08-10: проверка длины погасила
  «Применить» и ничего не объяснила)
- `operations`: не удваивает ли новая ветка расход лимита метабаз и платного
  LLM — повтор, ретрай, «распознать заново» на том же входе? (кэша ответов нет,
  задача `metadata-cache`)
- `operations`: копится ли то, что заводит это изменение, без срока хранения —
  строки терминальных задач, сырые ответы, файлы? (авточистки в проекте нет,
  задача `db-retention-cleanup`)
- `architecture`: не появился ли второй способ делать то, что уже делается —
  второе место, где генерится время или id, второй парсер источника, вторая
  логика целевых имён мимо `naming`?
  ([architecture.md](architecture.md) → «Единые точки проекта»)

### Триггеры метки

Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `medium`;
миграция схемы и публичный контракт метку **не** поднимают: их проверяют
проходы, которые в `medium` и так есть. Списка три: два поднимают до `large`,
по одному на ось, третий опускает до `small`.

**Крупное здесь** — про объём, сколько узлов и слоёв трогает изменение:

- перенос ответственности между `worker`, `recognition`, `layout` и `store`;
- новое состояние в графе переходов загрузки: оно тянет за собой воркер, спеку,
  отображение в веб-UI и боте и восстановление после рестарта;
- правка, идущая насквозь по цепочке приём → распознавание → раскладка;
- новый провайдер метабазы за существующим интерфейсом: клиент, поле конфига с
  образцом, слияние полей кандидата, ветка «провайдера нет».

**Незнакомое здесь** — про форму решения, которую предстоит нащупать по ходу:

- новый пакет `internal/*` или новая capability в `openspec/specs/`;
- новый транспорт приёма или уведомлений рядом с REST, веб-UI, ботом и CLI;
- заводится или меняется **правило идентичности, слияния или разбора**: ключ
  владения раздачей и сверка с qBittorrent (`ident`, `internal/store`,
  `state-reconciliation`); построение целевых путей и санитизация имён
  (`internal/layout`, `naming`); новый вид входа или новая ветка неоднозначности
  у разбора недоверенного — bencode, magnet, текст контекста, ответ LLM
  (`internal/torrent`, `internal/magnet`, `internal/tgbot/parse.go`); новый
  источник или новый победитель при конфликте в слиянии кандидата метабазы
  (`internal/metadata`, `metadata-match`); merge-раскладка при повторном
  добавлении раздачи.

- **новая поверхность поверх необратимой операции** — вторая точка входа в
  команду, которая удаляет файлы или снимает последнюю копию. Форма решения
  здесь нащупывается по ходу: подтверждение, порядок отказов, остаток,
  наблюдаемость. Выведено по факту на `bulk-delete-page` (журнал, 2026-08-10):
  метка `medium` не дала ни враждебного прохода, ни замера, а именно они нашли
  четыре дефекта класса «необратимо».

- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram** —
  для единственного пользователя это и есть интерфейс.

**Место из перечня метку не поднимает — поднимает правило.** Перечни выше
отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в
`large`». Метку поднимает то, что даёт работу новому проходу: заводится ключ
сравнения или меняется его состав; у разбора появляется новый вид входа или
новая ветка неоднозначности; в слияние добавляется источник или меняется
победитель при конфликте.

**Мелкое здесь** — опускает до `small`. Перечень закрытый, каждый пункт
проверяется взглядом на дифф и дельта-спеку, любое сработавшее держит метку
внизу:

- **дельта-спека называет исход поимённо** — сценарий уже говорит, что даёт
  вырожденный вход, и решать в коде нечего;
- **новых сценариев в дельта-спеке нет** — изменение уточняет уже описанное
  поведение, а не заказывает новое;
- **правка сообщения, комментария, записи журнала, имени или теста** в узле из
  перечней выше;
- **сужение уже существующей нормализации** без нового вида входа: вход остался
  тот же, изменился исход на одном его значении.

Отрицательный тест поверх перечня: что после мерджа не откатывается обратной
правкой — миграция, формат на диске, публичный контракт, имя, — **не** `small`,
каким бы маленьким ни был дифф.

**Ориентир частоты.** `medium` закрывает большинство задач, `large` рассчитана
на 5–10% и приходится на крупную функциональность, а не на уборку: задача типа
`chore` или `fix`, собранная из нитей прошлого ревью, идёт в `small` или
`medium`, даже когда трогает файл из перечней выше. `large` чаще одной задачи из
десяти означает ошибку в критерии, а не полосу сложных задач подряд.

### Недоступно проверке

**Не проверит ни один проход** — принципиальная граница, по факту промаха не
пересматривается.

- `operations`: история инцидентов на umbar и то, что уже ломалось в проде.
- `operations`: поведение таблицы SQLite под реальным объёмом и профилем
  нагрузки — реального профиля нет ни у кого, кроме сервера.
- `architecture`: завязка внешних потребителей (Jellyfin, закладки, чужие
  ссылки) на текущее поведение.
- `architecture`: суждение «этой функциональности не должно существовать».
- `requirements`: качество распознавания как таковое — правильно ли LLM
  определил фильм. Это вопрос тюнинга модели и промпта, а не ревью кода;
  размеченный корпус, по которому это можно было бы судить числом, решено не
  собирать (`tasks/REJECTED.md`, 2026-08-06).

**Перестали проверять сознательно** — пересматривается первым, как только
что-то проскочило.

- `conventions`: **идиоматичность Go — с 2026-08-04.** Проектный проход `idiom`
  (поимённая сверка с положениями Effective Go, Go Code Review Comments,
  стайлгайдов Uber и Google) упразднён вместе с переездом конвейера в плагин
  ([ADR-2026-08-04-review-pipeline-to-plugin](adr/ADR-2026-08-04-review-pipeline-to-plugin.md));
  способные части переселены — в тему `operations` (эксперимент против
  поведения библиотеки и драйвера) и в `architecture` («не изобретаем ли то,
  что уже есть в библиотеке»). **Различение «идиоматично против
  распространено» теперь не спрашивает никто.** Класс обратимый: портит форму
  кода, не данные. Пересмотр — задача `quality-review-agents`.
- `security`, `operations`, `architecture`: на метках `small` и `medium` не
  проверяется ничто, требующее запуска, — построенных путей атаки, замеров и
  эксплуатационного постмортема там нет по устройству конвейера. Их даёт только
  `large`, а она приходится на 5–10% задач.

## Журнал дефектов

Запись на каждый воспроизведённый дефект, **сразу**, а не ретроспективно: со
временем теряется не факт, а причина непоймания. Проскочившие — эвал-сет для
калибровки конвейера, выборка по пометке.

Форма:

<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]

- **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** как обнаружилось, кем и когда
- **Причина:** что на самом деле было не так
- **Чем воспроизведён:** тест, команда, замер — с числами
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
  и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
  проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->

### Записи

Журнал заведён 2026-07-23 вместе с переработкой конвейера
([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md));
случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна.

## 2026-08-10 — метка занижена: новая поверхность поверх необратимой операции прошла как среднее знакомое [пойман]

- **Где:** конвейер, а не код — разметка задачи `bulk-delete-page`
- **Симптом:** прогон по метке `medium` закончился шестью находками, из них ни
  одной про необратимое. Сигнал «метка, вероятно, занижена» вернули два прохода
  из четырёх — `code` и `basics`, с одинаковым основанием: дифф трогает `store`,
  `worker`, `httpapi`, `tgbot` и шаблоны и заводит новую точку входа поверх
  команды, удаляющей файлы
- **Причина:** обе оси считались по объёму и по знакомости узлов, и по ним
  изменение честно выходило средним и знакомым — цикл над готовым `Delete`. Ни
  один триггер `large` не описывал случай «поверхность новая, а операция за ней
  необратимая»
- **Чем воспроизведён:** повторная разметка после правок дельта-спек вернула
  `large`; догнанные проходы дали четыре находки класса «необратимо», из них три
  с прогнанными падающими тестами (`TestAdversaryDoneRowIsLastCopyWithoutWarning`,
  `TestAdversaryDeleteWipesSourceOfAnotherActiveDownload`) и одна с замером
  удержания общего замка воркера (`280.450631ms` при задержке соседа `300ms`)
- **Что меняем:** в «Триггеры метки», ось «незнакомое», добавлен пункт про новую
  поверхность поверх необратимой операции

## 2026-08-10 — три дефекта поштучного удаления жили незамеченными, пока рядом не появилась пачка [проскочил]

- **Где:** `internal/worker/review.go` (`Delete`), `internal/worker/worker.go`
  (`Poll`)
- **Симптом:** враждебный и эксплуатационный проходы на задаче
  `bulk-delete-page` нашли три дефекта, ни один из которых эта задача не
  вносила: удаление сносит раздачу, которой владеет **другая активная** загрузка
  с тем же инфохэшем; удаление держит общий замок воркера через сетевой вызов и
  останавливает фоновую работу на это время; при недоступном qBittorrent задача остаётся `done` весь простой
  соседа, потому что сверка возвращается на первой же ошибке и до коррекции не
  доходит
- **Причина:** поштучное удаление ни разу не проверялось меткой `large` — ни
  построенного пути, ни замера против него не гонял никто
- **Чем воспроизведён:** тесты и замеры перечислены в
  `openspec/changes/archive/2026-08-10-bulk-delete-page/review/report.md`,
  находки 2–4
- **Почему не поймали:** проходы, находящие этот класс, живут в метке `large`, а
  задачи, заводившие и правившие `Delete`, шли ниже. Дефект не «пропустил
  проход» — проход не запускался
- **Что меняем:** три записи в беклоге со ссылкой на оракулы; триггер метки
  дополнен (см. запись выше), чтобы следующая поверхность над необратимой
  операцией шла сразу с доказательными проходами

## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]

- **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading`
- **Симптом:** проход `autotests` на ревью кода change `card-live-refresh` заметил,
  что тест «фрагмент отдаётся без атрибутов поллинга» больше не может упасть
- **Причина:** change снял `hx-get`/`hx-trigger` с партиала `progress`
  **безусловно**, а тест утверждал их отсутствие только для завершённой задачи.
  Утверждение стало истинным при любом входе — тест перестал проверять что-либо,
  оставаясь в дереве как доказательство поведения
- **Чем воспроизведён:** `git diff` шаблона против тела теста; проверка инверсией
  невозможна по построению — сломать реализацию так, чтобы тест покраснел, нечем
- **Что меняем:** ничего в гейте. `diff-coverage` меряет **исполнение**, а не
  проверку, и такой класс не видит по устройству — 24/24 строк были покрыты при
  зелёном тесте-пустышке. Ловится либо мутационным прогоном (в гейт не заводим:
  цена выше пользы на нынешнем объёме), либо тем же вопросом темы `autotests`
  («есть ли тест, который упал бы без этой правки») — он и сработал. Тест
  переформулирован на то, что теперь является предметом: вне `downloading` блок
  живых цифр не рисуется вовсе

## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман]

- **Где:** `internal/layout/layout.go` — `BuildLinks`; `internal/worker/review.go`
  — сборка предпросмотра; `web/templates/partials/review_main.html` — панель
  действий. Норма — `openspec/specs/review/spec.md`, требование «Панель действий
  при пустом предпросмотре называет причину».
- **Симптом:** новая проверка длины целевого имени встала в `BuildLinks` —
  единственную сборку пути, откуда строятся и оба предпросмотра ревью. Отказ
  обнулил предпросмотр, а без него экран прячет команду «Применить». Текст
  причины брался из `error_msg` последнего перехода, но на самом частом входе
  (распознавание без матча) это поле пусто: задача приходит в `review` без
  причины и до раскладки не доходит. Владелец видел «Подтверди источник» и не
  видел ни кнопки, ни настоящей причины. **До изменения** предпросмотр строился,
  кнопка была, и применение доводило хотя бы до `failed` с текстом ядра.
- **Почему не поймали раньше:** обе стороны выглядели верными по отдельности.
  Проверка в общей точке — правильное решение, оно и дало совпадение показанного
  с применённым. Текст из `error_msg` — тоже правильное, для того входа, который
  разбирали на чекпоинте (ручное «Применить» причину записывает). Развилка была в
  том, что вход не один, и второй — частотнее.
- **Чем ловится теперь:** причина считается на показе
  ([ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md)),
  тесты `TestReviewData_PreviewErrorWhenStateHasNoReason`,
  `TestActionBarNamesReasonWithoutPreview`, `TestReviewCard_NamesPreviewProblem`.
  В вопросы темы `operations` добавлен вопрос про сужение видимого.

## 2026-08-10 — спека нормировала случай, которого код произвести не может [пойман]

- **Где:** дельта `openspec/specs/file-layout/spec.md`, сценарий про
  унаследованную от папки-якоря базу.
- **Симптом:** на чекпоинте ревью дизайна был назван тупик — при живом якоре база
  берётся с диска, подсказка её не укорачивает, задача циклится. Сценарий уехал в
  спеку с инструкцией человеку («выбрать источник без базы либо переименовать
  папку руками»). Попытка написать под него тест показала, что случай
  **недостижим**: папка-якорь лежит на диске и потому уже не длиннее предела, а
  хвост имени файла (`" S02E01"` плюс расширение, 11 байт) не длиннее хвоста
  имени папки (`" ["` плюс provider-тег плюс `"]"`, минимум 11 байт).
- **Почему не поймали раньше:** случай звучал правдоподобно и опирался на верное
  свойство (унаследованная база подсказкой не меняется). Ни один проход ревью
  дизайна арифметику не считал — кода на той стадии нет, а сценарий выглядел как
  описание существующего поведения, а не как гипотеза.
- **Чем ловится теперь:** сценарий переписан на верное утверждение, свойство
  закреплено тестом `TestApply_InheritedBaseAtLimitStillFits` — он покраснеет,
  если суффиксы имён вырастут. Урок общий: **сценарий, который нельзя
  воспроизвести, обязан получить оракул до того, как попадёт в спеку**; норма,
  описывающая недостижимое, не отличается от неверной.

## 2026-08-10 — чистка названия метабазы стояла только на записи, и очередь ревью её обходила [пойман]

- **Где:** `internal/worker/review.go` — `sourcePins`, `applyOverrides`,
  `buildSources`. Норма — `openspec/specs/metadata-match/spec.md`, требование
  «Санитайзинг названий кандидатов, уходящих в ревью», и
  `openspec/specs/review/spec.md`, «Подтверждение матча обновляет отображаемое
  имя».
- **Симптом:** найден на ревью самой задачи `metadata-title-sanitize`, до
  мерджа. В эксплуатации не всплывал. Сошлись независимо четыре прохода:
  `adversary` (построенный путь с падающим тестом), `ops` (постмортем),
  `specs` и `code`.
- **Причина:** правка чистила значение метабазы **в момент записи** — при
  копировании кандидата в список для ревью. Из этого следовали три дыры разом.
  (1) Кандидаты, сохранённые прежними версиями, лежат в хранилище грязными, а
  их выбор человеком закреплял название дословно. (2) `applyOverrides` читал
  значение, закреплённое до деплоя, дословно, и обычное «Применить» без
  повторного выбора источника создавало ровно тот каталог, ради которого
  правка затевалась. (3) Предпросмотр источника на экране считался из сырого
  названия, а гейт пригодности стоял только на закреплении — экран показывал
  одно, раскладка делала другое, при том что «превью = применение» записано
  требованием `web-ui`.
- **Чем воспроизведён:** тремя тестами, каждый падает без правки (проверено
  прогоном с временно снятой правкой): `TestBuildSources_PreviewMatchesApply`,
  `TestChooseCandidate_DirtyLegacyTitleSanitized`,
  `TestApplyOverrides_LegacyDirtyPinSanitized`. Плюс прогон `adversary`:
  превью `- (2014) [tvdbid-269613]/…` против применяемого
  `Догадка (2014) [tvdbid-269613]/…` на одном экране.
- **Почему не поймали:** ловить было нечему — дефект поймали на этом же ревью,
  до мерджа. Записывается ради причины его появления: **гарантия, поставленная
  на запись, молчаливо не распространяется на данные, записанные раньше.**
  Ревью дизайна дошло до «закрыть оба пути подтверждения матча», но точкой
  закрытия выбрало запись, а не чтение; вопрос «а что с тем, что уже лежит в
  хранилище» не задал никто из трёх проходов стадии дизайна. Его задал
  эксплуатационный проход — на оси времени, где он и живёт.
- **Что меняем:** вопрос темы `operations` (ниже) — про гарантию, поставленную
  на запись. Решение по существу — ADR-2026-08-10-sanitize-at-every-entry:
  чистка стоит на каждой точке входа, включая чтение.

## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]

- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
  `:550`. Норма — `openspec/specs/download-tracking/spec.md`, требование
  «Добавление пойманной загрузки в qBittorrent».
- **Симптом:** найден проходом `adversary` на ревью задачи
  `dismiss-marker-lost` (2026-08-06), не в эксплуатации. В проде не всплывал.
- **Причина:** гард «подтверждённое отсутствие непосредственно перед `add`»
  подтверждает отсутствие **записи торрента** в qBittorrent, но не отсутствие
  **данных** на диске. Пользователь, снявший раздачу из qBittorrent с
  сохранением файлов (`download-tracking` сама предписывает это как способ
  восстановления зависшей magnet-раздачи), и подавший тот же торрент заново,
  получает `add`, подхватывающий пред-существующие файлы. Отмена в окне между
  re-read и `PromoteCatched` даёт `torrents/delete` с `deleteFiles=true` по
  этим файлам. Исключение инварианта «источник неприкосновенен» покрывает
  «собственный торрент», а признак «своё» подменён на «торрента не было».
- **Чем воспроизведён:** тестом на фейковом клиенте qBittorrent во временном
  каталоге прогона (`tmp/`, не сохранён): `Cancel` в окне после `add` вызывает
  `Delete(hashes, deleteFiles=true)` при живом файле под `paths.downloads`,
  созданном до `add`. Тот же путь достижим через `Dismiss` — гейт `Dismiss`
  шире, а уборка срабатывает по состоянию (`after.State != catched`), а не по
  команде. **Не прогонялся** последний шаг — что боевой qBittorrent по
  `deleteFiles=true` физически сносит пред-существующий контент: в бой ходить
  запрещено, отсюда `Confidence: medium`.
- **Почему не поймали:** окно после `add` разбиралось как **гонка** (кто
  успел — отмена или промоушен) и проверялось на «не удалим ли чужой торрент».
  Вопрос «а если торрента нет, но данные есть» не задавал никто: ни один
  проход не спрашивал про **асимметрию признака владения** — признак снимается
  с одной сущности (запись в qBittorrent), а действие применяется к другой
  (байты на диске). Враждебный проход до этой задачи на данном коде не гонялся.
- **Что меняем:** в «Вопросы по темам» добавлен вопрос темы `security` про
  асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат
  `critical`; спека `state-reconciliation` в том же изменении перестала
  утверждать, что уборка «данных пользователя не касается».

## 2026-08-06 — нормализация имени раздачи сама производила сентинел, который отбрасывала [пойман]

- **Где:** `internal/torrent/torrent.go`, функция `displayName` (введена
  изменением `ingest-nits`). Норма — `openspec/specs/ingest/spec.md`, требование
  «Вырожденное имя раздачи не считается именем».
- **Симптом:** найден на ревью изменения, до мерджа. В эксплуатации не был.
- **Причина:** сравнение с `metainfo.NoName` стояло **до** схлопывания
  пробельного. Имя `" - "` сравнение не проходило, а `oneLine` превращал его
  ровно в `-`, и вырожденное значение уезжало вниз по потоку всеми тремя
  путями: строкой названия в контексте распознавания, в `source_ref` (фолбек на
  имя присланного файла не срабатывал — строка непуста) и подсказкой вывода
  отображаемого имени. Против `master` это **регрессия**: там стояло
  `strings.TrimSpace(...)` перед сравнением с `-`, и фолбек работал.
- **Чем воспроизведён:** двумя независимыми падающими тестами — `adversary`
  прогнал приём на входах `" - "`, `"-\n"`, `"\t-"`, `" -"` и получил
  `source_ref = "-"` вместо `Dune.torrent`; `reimpl` принёс свой тест на разбор.
  В дереве остался табличный `TestParseNoNameSentinelDropped`.
- **Почему не поймали раньше:** ловить было нечему — дефект внесён этим же
  изменением и пойман тем же прогоном. Отмечено потому, что это **эвал-сет
  наоборот**: случай, где старшая метка окупилась. Три прохода из семи
  (`specs`, `adversary`, `reimpl`) нашли его независимо, и двое принесли оракул;
  проход `code` (конвенции) и гейт его не видели — порядок двух операций внутри
  функции не выражается ни правилом линтера, ни конвенцией.
- **Что меняем:** ничего в конвейере. Класс «нормализация и сравнение с
  константой идут в неверном порядке» дешевле ловить тестом на границе разбора,
  чем правилом; такой тест заведён. Наблюдение о самой библиотеке (что
  `BestName()` сентинел **не** синтезирует — исходное основание нити было
  неверным) записано в `research/torrent-bencode-limits.md`.
