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

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

Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
`code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
прогоном, и смотреть его надо в самом архиве.

Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
прогонов.

**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
правила, оба прозой и без механизации: **поднял — останови за собой**, а
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.

**Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.

Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Состав прогона при этом был самым широким из
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.

Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
узлах».

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

Рода узлов проекта и проверяемые свойства к каждому.

**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`):

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

**Транспорт** (`internal/controller/http`):

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

**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
шаг `front`):

- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
  корней порождает регистрацию маршрутов, а не описывает её;
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
  кодом `200`;
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
  сборщика: отозвать его у браузера сервису нечем;
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
  нет» отличается от «файла нет»;
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
  **отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
  Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
  ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
  адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
  пишется дословно ни под каким корнем, включая корень приложения.

**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):

- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
- различает «сервис ответил отказом» и «сервис недоступен»;
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
  успех молча.

**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`):

- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
  `writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
  (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
  **именами**: именованный параметр запроса и место назначения по имени, а не
  позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
  держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
  из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
  целиком.

**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):

- отсутствие программы в `PATH` отличается от отказа обработки;
- вход, пришедший от пользователя, не попадает в аргументы командной строки
  неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.

**Любой узел** — сверх свойств своего рода:

- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
  никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
  становится неотличим от привычного шума (журнал, запись 2026-08-10).

Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».

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

- **«Хранилище молча сливает две учётные записи с одной почтой в одного
  владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
  половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
  найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
  признак он не может — уникальный индекс
  `idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
  отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
  `Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
  не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
  Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
  исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
  без прогона, здесь и измерена.
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
  в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
  логирует его и не считает в метрику. Норма записана требованием
  [pipeline](../openspec/specs/pipeline/spec.md).

  **Оговорка, и она тут главная:** ложноположительным считается только само
  молчание воркера. Проверка **формы** узнавания ложноположительной не является:
  приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
  `errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
  её как известную нельзя.
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
  нет: три воркера читают три разных состояния, и одну строку они не делят.~~
  **Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
  одинаковых воркеров конкурирует за один и тот же набор записей, и второй
  воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
  гонке захвата стала настоящей и выбрасывается только по существу — механика
  захвата и её слабые места в [database.md](database.md), «Представление
  данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
  редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
  [database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
  Новой находкой это не считается, пока не измерен рост.
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
  обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
  иначе выбросил бы настоящую находку как известную.

  До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
  дефект и не новость»: разграничения не было сознательно. Первая отмена
  2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
  владельца, принятую ботом.

  Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
  колонка владельца пустого значения больше не принимает, ничьих записей у
  сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
  а не известное исключение.

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

Форма: `<тема>: <вопрос> (<откуда>)`.

- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
  в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
  `cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
  же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
  «Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
  `cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
  он: заголовки входа подставляет сам сервис под предохранителем
  `[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
  держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
  закрыт той же задачей:** норма о подставных собеседниках записана в
  [architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
  в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
  Вопрос при этом остаётся вопросом:
  норма называет, где собеседнику жить, а не сколько домов у него уже завелось
  (ревью задачи про заглушку OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
  внешнего собеседника и это держат правила `noctx` и `contextcheck`
  ([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
  собеседник»), а исход прерванного шага нормой по-прежнему не описан
  (`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
  делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
  `transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
  дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
  из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
  контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
  `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
  возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
  2026-08-10).
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
  URL с токеном бота (запрет в [security.md](security.md) и
  [conventions/logging.md](conventions/logging.md)).
- `security`: не строится ли путь на диске или ключ объекта из значения,
  пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
  (чтение `service/transcribe.go`, 2026-08-10).
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
  метрик отдаётся без проверки отправителя, и метка это поверхность пошире
  журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
  он единственный, которым запись попадает в хранилище
  ([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
  заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
  под маркерами долга, а соблазн дописать туда ещё — самый большой.
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
  рубеж — одним дескриптором
  (CLAUDE.md, «Инварианты»).
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
  прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
  остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
  2026-08-15 про единую форму отказа).
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
  заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
  сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
  сканирование таблицы и рост времени страницы вместе с **чужими** записями.
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
  ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
  вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
  страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
  тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
  [conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
  числа файлов здесь не называем — оно протухает с каждой задачей.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
  состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
  свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
  2026-08-12 роняет гейт правилом линтера
  ([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
  спрашивать о нём не нужно.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
  собственная регистрация, вход по паролю, одноразовый код, восстановление
  доступа, продление сессии. Всё это приходит включённым и закрывается нами
  (задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
  заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
  после входа сервис к провайдеру не обращается, и канал здесь один
  (ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
  приложения, — сборка роутера хранилища оказалась именно такой.

### Когда звать глубокое ревью

Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
здесь только места этого проекта.

**Смотрим целиком** (область кода, а не дифф задачи):

- **вход и разграничение доступа** — звенья `internal/controller/http`
  (узнавание, требование учётной записи, ограничитель частоты, подстановка
  заголовков отладочного запуска) вместе со спекой
  [access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
  то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
  которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
  ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
  журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
  2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
  запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
  прогоне нет (см. «Недоступно проверке»);
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
  «Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
  «Инварианты», major): места, цена забытого и то, чем держится сверка, названы
  там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
  `internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
  дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
  забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
  второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
  сервиса, хоронившая конвертируемую запись;
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
  чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
  деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
  проверке»), и разбор остаётся единственным способом судить о нём.

**Необратимое здесь.** Перечень необратимого один и лежит в
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
пункты касаются, и правило прохода: находка в таком месте уходит человеку
развилкой, а не чинится молча.

- применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
- формат файла на диске и раскладка каталога данных —
  `internal/adapter/repo/sqlite`, `store.go`;
- публичный контракт HTTP API — `internal/controller/http`;
- имя ключа конфига — `internal/config`;
- действие с боевыми данными и с Yandex Cloud, ротация секрета —
  `internal/adapter/recognizer/yandex`.

Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
там только следующей записи.

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

**Не проверит ни один проход:**

- `operations`: поведение внешних сервисов под нагрузкой и на границах —
  SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
  день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
  отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
  сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
  **весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
  пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
  ([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
  2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
  панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
  как возврат недоступного проверке, а не как обычная работа.

**Перестали проверять сознательно:**

- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
  2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
  длительность от подставного источника. Своего теста у
  `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
  [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
  можно: он встаёт своим единственным входом на выдуманных непустых ключах
  секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
  осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
  предметов осмотра нет с 2026-08-22.
  Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
  `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
  с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
  дня), а 2026-08-14 признак включения ушёл вместе с самим входом.

  **Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
  не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
  коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
  остановку; нельзя — расшифровку и заливку.

  **Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
  сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
  секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
  `cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
  заведение учётной записи первым обращением, отказ с недоверенного адреса и
  отказ старта на пустом перечне.
  Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
  контуре (см. «Не проверит ни один проход»).

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

Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.

## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]

- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
  `storage-without-pocketbase`
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
  Путь под корнем приложения уходил в строку дословно — в том числе при ответе
  `401`, потому что слой журнала стоит снаружи ограничителя частоты
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
  для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
  сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
  под корнем распоряжается тот же аноним
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
  прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
  и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
- **Почему не поймали раньше:** правило записали по месту, где его впервые
  понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
  Зазор был ровно шириной в корень приложения
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
  длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
  закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
  переписано на все корни разом — оно стоит теперь у строки о всяком входящем
  запросе, а не у строки о раздаче приложения

## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]

- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
  `storage-without-pocketbase`
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
  бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
  адресов
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
  заголовок дописывает, а не заменяет. Левым значением распоряжается сам
  спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
  запросе
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
  при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
  свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
  конвенции, ни в типовом узле — его держала прежде чужая библиотека
- **Что меняем:** цепочка читается справа налево, доверенные адреса
  отбрасываются, ключом становится первый недоверенный, а заголовок читается
  всеми строками, а не одной. Требование к контуру этим снято: дописывающий
  прокси правилом покрыт — [security.md](security.md), «Периметр»

## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]

- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
  `internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
  и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
  на инвариант как на оракул стало нельзя
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
  инварианта остался от прежней. Мест при этом стало три: что спрошено
  (`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
  (`rowToAudioRecord`), — а сверялось правилом одно
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
  `rowToAudioRecord` в `internal/archrules` — пусто
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
  тексте — ничем. Текст и сторож разошлись молча
- **Что меняем:** инвариант назван действующими именами и действительным числом
  мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
  колонок чтения сверяется с перечнем присвоений в сущность

## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]

- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
  доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
  местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
  и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
  журнале хранилища
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
  найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
  а признак провайдера на записи уникален — `idx_externalAuths_record_provider` —
  и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
  из `sub`
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
  задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
  читатели именно её
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
  Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
  затем счёт записей в коллекции пользователей. Само умолчание почты теперь
  выводится из `-sub`

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

- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
  задача `spa-skeleton`
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
  хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
  суток. При этом строка `docs/review.md`, добавленная той же задачей,
  утверждала, что путь анонима в журнал не идёт
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
  признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
  разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
  признак при переписывании не перенесён.
  Журналов у сервиса два, а сделанная защита закрыла один
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
  проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
  один тест туда не смотрел
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
  чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
  два отказа оставляют

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

- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
  `json-api-for-spa`
- **Симптом:** три отказа под корнем приложения — превышение
  потолка тела, ограничитель частоты и неизвестный путь — уходили телом
  библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
  на адресах приложения было две, а не одна, — ровно то, ради чего задача и
  заводилась
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
  что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
  слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
  которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
  все три случая как закрытые
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
  Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
  сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
  что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
  производителя в рабочем коде
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
  запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
  вопросом в раздел ниже

## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]

- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
  и `StructureRepository.Put`; путь до них — `poll` → `storeOutcome` в
  `internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
  касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
  считается — наружу уходит пустой результат без отказа. Замена содержимого шла
  безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
  расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
  готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
  от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
  и структура реплик такого условия не имели. Разное правило у двух хранителей
  одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
  `expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
  `internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
  он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
  и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
  отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
  такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
  нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
  текст, больше одного, и правило у одного из них у остальных читалось бы как
  снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
  задаче, которая его нашла

## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]

- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
  `docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
  успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
  в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
  вместе с ней — при том, что конвенция журнала называет пустой текст
  распознавания поимённым примером уровня «может стать проблемой», а обзор
  архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
  `TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
  готовую операцию с пустым результатом, проверка судит уровень строки и
  идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
  признака, а не сам признак; такое не видно ни компилятору, ни грепу по
  удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
  что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
  идентификатором записи; строка обзора архитектуры переписана на фактическое
  поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
  потребителем сигнала** — сигнал переживает канал только там, где его переносят
  руками

## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]

- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
  попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода
  сервиса он не касался
- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()`
  не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею
- **Причина:** двойная, и каждая половина достаточна. Утверждение искало
  подстроку `enabled = true при`, а в сообщении стоит `при enabled = true` —
  порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:
  `Validate()` отказывает **только** на пустом ключе, то есть значения, которым
  можно проговориться, на этом пути не существует вовсе. Комментарий при этом
  утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот
  вход, который задан
- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил
  тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`.
  Проверка осталась зелёной
- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой,
  что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали
  два прохода независимо — разбор кода и сверка требований
- **Что меняем:** проверка переписана честно и переименована: половина требования
  «сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а
  настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ
  в отказ попасть действительно может, в проверках отказа разбора файла настроек.
  Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть»,
  2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот
  раз он другой природы: прежние два ловились правилом линтера про источник
  утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать
  значение, которое может утечь, иначе сторож пуст независимо от формы
  утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь
  вообще утечь» — суждение, а не форма. Остаётся проходу ревью

## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]

- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
  же правка, что проложила контекст до `ffmpeg`
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
  выкладка посреди конвертации переводит здоровую запись в терминальное
  `failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
  только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
  идёт дольше часа по построению
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
  не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
  настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
  `errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
  `Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
  успешным, и метрика владельца — та, которой он замечает отказы, — не
  шевелилась
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
  с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
  несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
  контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
  значить разное на соседних шагах одного конвейера. Ни один линтер такого не
  видит — это заметили три прохода ревью независимо, и все три построили путь
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
  повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
  отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
  отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
  остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
  открытая задача `context-cancel-in-pipeline` этим закрыта не целиком

## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]

- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
  `file.Link(c.bot.Token)`
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
  построчно. Первый же сбой сети на скачивании писал в журнал
  `Failed to download audio file` вместе с полным адресом запроса, а в адресе
  Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
  конфиг» помечен critical и необратим: утёкший токен отзывают руками
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
  целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
  оттуда — в `logger.Error` соседней строкой
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
  в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
  запрещено
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
  случай — [conventions/logging.md](conventions/logging.md), «Ошибка
  HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
  оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
  неверной. Строка лога существовала всё это время, но проза о ней не знала, а
  машина прозу не проверяет
- **Что меняем:** чистку перенесли с места употребления на **границу клиента** —
  `internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
  первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
  опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
  больше не получает: клиента ему отдают готовым. Расхождение в конвенции
  закрыто, оценка в [security.md](security.md) исправлена
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
  — четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
  судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
  три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
  ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
  потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
  прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
  закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
  осталось так

## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]

- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
  результата распознавания
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
  потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
  и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
  как готовый результат
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
  сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
  условие перестанет срабатывать вовсе, и оба исхода молчаливы
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
  ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
  но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
  не спрашивало
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
  тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)

## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]

- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
  заголовков — заведено в тот же день задачей `response-assertions-judge-result`
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
  в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
  `HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
  гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
  успели снять его с прохода ревью
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
  типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
  свойство
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
  чтения живой карты: помечена была одна
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
  которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
  `httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
  проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
  строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
  стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
  раздел снят 2026-08-13, урок остался здесь

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

- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
  в коллекции пользователей
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
  владельца панели. Запись при первом входе заводит внутренний запрос самого
  обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
  у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
  доступен только через Telegram, и чинилось бы это руками в панели
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
  регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
  надёжным её закрытием и отвергает заодно единственный законный путь заведения
  записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
  провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
  `1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
  с открытым правилом возврат давал `302` и запись появлялась
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
  прямым сохранением, мимо входа, и потому шли по коду, который в бою не
  исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
  исходникам библиотеки и враждебный проход падающим тестом
- **Что меняем:** правило сузили до контекста обмена
  (`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
  подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
  мимо входа, больше не считается покрытием входа

## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]

- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
  состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
  возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
  держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
  была зелёной и утверждала обратное
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
  начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
  правка их карты до браузера не доезжает. В проверке — `httptest` устроен
  зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
  через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
  получит
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
  ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
  показывал куку в `Header()` и не показывал в `Result()`
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
  регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
  требований и разбор кода, — оба воспроизведением, а не чтением
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
  судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
  http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
  могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
  судит по готовому ответу, а не по изменяемому состоянию обработчика.
  Механизировано 2026-08-12 задачей `response-assertions-judge-result` —
  `forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
  файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
  потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
  индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
  цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
  задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
  `http.ResponseWriter`

## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]

- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
  хранилища на каждый вызов
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
  без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
  Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
  каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
  атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
  величины, а обмен исполняется раньше обращения к провайдеру
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
  не видно, что она правит приложение. Решение звать собственный адрес хранилища
  внутри процесса сделало эту сборку частью горячего пути
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
  подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
  довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
  недоступном провайдере утечка сохранялась
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
  вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
  зондом, враждебный падающим тестом, сверка требований чтением
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
  проверок добавлена та, что считает длину очереди обработчиков после двадцати
  входов

## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]

**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` —
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
`pocketbase-storage` не существовало бы вовсе.

**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.

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

**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.

**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
комментариями в самом скрипте.

## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]

- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
  об отказе шага
- **Симптом:** требование гласило «отказ MUST быть записан **ровно один раз**
  единственной логирующей точкой». Сервис пишет дважды — сначала шаг конвейера,
  следом воркер, — то есть норма не выполнялась бы с первого дня, а после
  архивации стала бы посылкой для следующих задач
- **Причина:** дефект родился при починке соседнего. Первая редакция назначала
  логирующей точкой воркера и фиксировала уровень `ERROR`, чем закрепляла
  контрактом долг `conventions/logging.md`. Правка по этой находке ушла в
  противоположную крайность: вместо «норма молчит о числе записей» получилось
  «норма требует одной». Двойная запись — записанный системный долг, и обе
  редакции с ним расходились, только в разные стороны
- **Чем воспроизведён:** прогоном пробы через `go test -overlay`: один отказ
  хранилища даёт две записи — `Failed to find and acquire job` из шага и
  `Worker error` из воркера
- **Почему не поймали раньше:** требование не имело сценария, а значит и оракула
  — упасть ему было нечем. Ревью дизайна абзац читало, но код с ним не сверяло:
  кода тогда не существовало. Поймал проход `specs` на ревью кода, направлением
  `code → spec`, и поймал прогоном, а не чтением
- **Что меняем:** норма говорит только проверяемое сегодня — отказ виден
  владельцу и засчитан в счётчик; число записей и уровень названы долгом с
  адресом. В критерии приёмки добавлена строка: норма не объявляет обязательным
  недостижимое — ни в ту, ни в другую сторону

## 2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]

- **Где:** `internal/service/transcribe.go`, метки `file_extension` у размера
  принятой записи и `source_format` у длительности конвертации
- **Симптом:** имя `запись.тайное-слово` клало `тайное-слово` меткой метрики, а
  `GET /metrics` отдаётся без проверки отправителя. Тем же каналом множеством значений
  метки распоряжался анонимный отправитель
- **Причина:** расширение берётся из имени отправителя дословно (`filepath.Ext`)
  и употреблялось меткой без приведения. Канал старше задачи, которая его нашла
- **Чем воспроизведён:** прогон `filepath.Ext` на именах вида
  `запись.тайное-слово`, `Разговор с Петровым 11.08`, затем чтение реестра
  метрик после приёма — метка несла хвост дословно
- **Почему не поймали:** метрику никто не считал выходом приватного значения.
  Тема `security` смотрела журнал, ответ и пути на диске; вопроса про метку в
  перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода
  разом на задаче, которая закрывала соседний канал
- **Что меняем:** вопрос про метку добавлен в «Вопросы по темам»; правило
  приведения нормировано спекой `intake` и записано
  [решением](adr/ADR-2026-08-11-known-format-label.md)

## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]

- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
- **Симптом:** тест не поймал ни одного настоящего дефекта приёма, хотя был
  зелёным и выглядел содержательным
- **Причина:** две штуки одного рода. Тест разбирал ответ в
  `CreateTranscribeJobResponse` — ту самую структуру, чьи теги `json` и
  составляют публичный контракт: переименование тега меняло и проверяемое, и
  ожидаемое разом. И заведение задачи тест подтверждал только эхом ответа, а не
  чтением базы
- **Чем воспроизведён:** мутацией. Замена тега на `json:"jobId"` и удаление
  `s.jobRepo.Create(job)` из `internal/service/transcribe.go` — тесты в обоих
  случаях оставались зелёными; после правки обе мутации их роняют
- **Почему не поймали:** проверки писались тем же заходом, что и правились, а
  «зелено» на новом тесте читается как подтверждение. Поймал проход `specs`
  ревью кода, и поймал ровно тем, что добыл оракул мутацией, а не рассуждением
- **Что меняем:** успех судится по сырому JSON и по строке в базе. В типовые
  узлы, «Любой узел», добавлено свойство «проверка способна упасть» с указанием
  на мутацию как способ его проверить

## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]

- **Где:** `internal/controller/http/transcribe_test.go`
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
  прогоном гейта при заведении канона
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
  не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
  файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
  `ffprobe`, который такой вход отвергает
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
  из них один по отсутствию файла и три по коду `500` вместо `201`
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
  результату, не гоняли ни разу с коммита `87d8b05`
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
  добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
  шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
  типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
  вопросы темы `autotests` — вопрос про изменённый шаг конвейера
- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из
  списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят

## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]

- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
  задача завершалась этим пустым значением
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
  текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
  сервиса не превращается в успех молча» вынесено в типовой узел «клиент
  внешнего сервиса» выше

## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]

- **Где:** `internal/adapter/telegram/sender.go`
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
  целиком, пользователь не получал ничего
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
  заходом завёл `internal/adapter/telegram/split_test.go`
- **Почему не поймали:** конвейера ревью не существовало
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
  число записано в [database.md](database.md)
