Files
transcriber/docs/review.md
T
av 31ce520c5b документы: сведены расхождения, найденные сверкой канона
- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
2026-08-23 16:41:06 +03:00

911 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью: настройка и журнал
## Как настроен конвейер
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
`triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
ранний — `fix-http-handler-tests` 2026-08-11, самый поздний —
`start-without-telegram-token` 2026-08-13.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
прогонов.
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
правила, оба прозой и без механизации: **поднял — останови за собой**, а
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
**Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Прогон при этом шёл с меткой `large`, то есть с
самым широким составом, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[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`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
**Крупное здесь** (поднимает до `large`, ось объёма):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, убирающее или возвращающее вход приёма целиком.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
записью, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
собой — форма решения нащупывается по ходу;
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
**Мелкое здесь** (опускает до `small`):
- новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
### Недоступно проверке
**Не проверит ни один проход:**
- `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)