# Ревью: настройка и журнал ## Как настроен конвейер Артефакты шести прогонов лежат в `openspec/changes/archive//review/`: у трёх ранних, начиная с `fix-http-handler-tests` 2026-08-11, это `triage.md`, у трёх поздних — `report.md`. Сверх них конвейер прогонялся 2026-08-13 на работе, шедшей без своего изменения openspec; артефакта в архиве у тех прогонов нет, и урожай их виден только записями журнала ниже. Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем прогонов. Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция [conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых узлах». ### Типовые узлы Рода узлов проекта и проверяемые свойства к каждому. **Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`, `FindAndRunTranscribeCheckJob`): - отличает «задач нет» от отказа и не считает первое ошибкой; - при отказе на середине оставляет задачу в состоянии, из которого повтор корректен, либо переводит в `failed` осознанно; - не теряет ссылку на файл: `job.FileID` переставляется только после того, как запись о новом файле создана; - повтор шага на той же задаче не создаёт лишних файлов и записей; - отвечает пользователю ровно один раз. **Транспорт** (`internal/controller/tg`, `internal/controller/http`): - проверяет право отправителя до всякой работы; - не логирует ошибку, которую уже залогировал доменный слой; - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - закрывает то, что открыл, на всех ветках выхода. **Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`): - имеет таймаут и не виснет, когда внешний сервис не отвечает; - не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта; - различает «сервис ответил отказом» и «сервис недоступен»; - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча. **Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — подпакетом `migrations`): - список колонок совпадает во всех четырёх местах — `applyToRecord`, `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»); - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только держатель захвата; - репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет свои `created`/`updated` ([database.md](database.md), «Представление данных»); - отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком. **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, `adapter/metaviewer/ffmpeg`): - отсутствие программы в `PATH` отличается от отказа обработки; - вход, пришедший от пользователя, не попадает в аргументы командной строки неразобранным; - пустой или частично записанный выходной файл считается отказом; - процесс не висит вечно. **Любой узел** — сверх свойств своего рода: - изменённое место покрыто хоть одним **проходящим** тестом. Тест, который никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём становится неотличим от привычного шума (журнал, запись 2026-08-10); - проверка **способна упасть**. Утверждение, разбирающее ответ в ту же структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал, запись 2026-08-11); - **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей единственный оракул — молчание линтера, годится ровно тогда, когда линтер краснеет на **всех** негодных реализациях; проверяется той же мутацией. Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`, а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком (журнал, запись 2026-08-11 про недостижимую норму; закрыто [решением](adr/ADR-2026-08-11-errcheck-check-blank.md)); - **требование без сценария не имеет оракула** и потому не может быть нарушено заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится тем вернее, чем убедительнее написана (журнал, запись 2026-08-11). ### Типовые ложноположительные - **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не логирует его и не считает в метрику. Норма записана требованием [pipeline](../openspec/specs/pipeline/spec.md). **Оговорка, и она тут главная:** ложноположительным считается только само молчание воркера. Проверка **формы** узнавания ложноположительной не является: приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей `errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать её как известную нельзя. - **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её нет: три воркера читают три разных состояния, и одну строку они не делят. Механика захвата и её слабые места — [database.md](database.md), «Представление данных». Находка становится настоящей ровно тогда, когда появится второй экземпляр процесса или второй воркер на то же состояние. - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в [database.md](database.md); срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост. - **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а разграничения по владельцу нет сознательно — [security.md](security.md), «Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая поверхность, выставленная наружу, либо путь к содержимому записи **без** сессии, а не повторение этого факта. ### Вопросы по темам Форма: `<тема>: <вопрос> (<провенанс>)`. - `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`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**: контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`, `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`: не появился ли второй путь приёма мимо `createTranscribeJob` — сегодня через него идут оба входа ([architecture.md](architecture.md), «Единые точки проекта»). - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и первые две описаны частично. Поведение прочих узлов, включая приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда ещё — самый большой. - `conventions`: новая колонка правится во всех четырёх местах репозитория (CLAUDE.md, «Инварианты»). - `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`: не зовётся ли на каждый запрос то, что меняет состояние приложения, — сборка роутера хранилища оказалась именно такой. ### Триггеры метки Проектная конкретизация правила выбора метки. Умолчание — `medium`. **Крупное здесь** (поднимает до `large`, ось объёма): - изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и колонку разом; - замена хранилища или переход на PocketBase — любой её кусок; - смена модели очереди: захват, повторы и воркеры разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - изменение, трогающее оба входа сразу — Telegram и HTTP. **Незнакомое здесь** (поднимает до `large`, ось формы решения): - вход через OIDC и разграничение доступа: как связаны пользователь Telegram и пользователь приложения, до начала работы назвать нельзя; - всё, что делается на выбранном фреймворке впервые: правила [conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера на пробном экране, а не из написанного кода, и первая же задача проверяет их собой — форма решения нащупывается по ходу; - установка на телефон: service worker перехватывает запросы, и что он кэширует, до работы назвать нельзя; - работа с записями в несколько часов: потолки внешних сервисов не замерены, форма решения зависит от замера; - приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой; - всё, что требует записи в `research/` прежде, чем начать. **Мелкое здесь** (опускает до `small`): - правка текста, который видит пользователь Telegram; - новая метрика в `internal/metrics`; - правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля; - правка документов канона. Помни отрицательный тест: миграция, формат файла на диске, публичный контракт API и имя не откатываются обратной правкой после мерджа — какими бы маленькими ни были, они не `small`. ### Недоступно проверке **Не проверит ни один проход:** - `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем; - `operations`: реальный профиль нагрузки. Проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами; - `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы; - `security`: поведение настоящей Authelia и её правило на нашего клиента. Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка выкладки вне репозитория, и по коду её не проверить ([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md)); - `security`: поведение браузера с куками — применение `SameSite`, приём `Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки этого рода остаются гипотезами. **Перестали проверять сознательно:** - `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); - **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер Telegram: он проверяет токен обращением к Telegram, а боевым токеном запускаться запрещено. Значит поведенческая верификация живым прогоном недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока нет. ## Журнал дефектов Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера, `[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. ## 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/…`). Инвариант «секрет не покидает конфиг» помечен 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-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`: в этом дефекте трое из четырёх врали согласованно, и парная сверка не увидела бы документ, разошедшийся с согласованным кодом. Норму шага держат его собственные проверки (`scripts/check_go_version_test.go`): спека `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)