# Ревью: настройка и журнал ## Как настроен конвейер Артефакты прогонов лежат в `openspec/changes/archive//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/…`). Инвариант «секрет не покидает конфиг» помечен 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)