- Двадцать сценариев шага были единственной проверкой над проверкой в проекте; запрет CLAUDE.md остался без исключений. - Ссылки на файл сняты в памятке, конвенции линтеров, журнале ревью и статусе ADR о спеке toolchain; норма шага живёт комментариями в самом скрипте.
579 lines
54 KiB
Markdown
579 lines
54 KiB
Markdown
# Ревью: настройка и журнал
|
||
|
||
## Как настроен конвейер
|
||
|
||
Артефакты шести прогонов лежат в `openspec/changes/archive/<id>/review/`: у трёх
|
||
ранних, начиная с `fix-http-handler-tests` 2026-08-11, это `triage.md`, у трёх
|
||
поздних — `report.md`. Сверх них конвейер прогонялся 2026-08-13 на работе, шедшей
|
||
без своего изменения openspec; артефакта в архиве у тех прогонов нет, и урожай их
|
||
виден только записями журнала ниже. Разделы ниже заведены наперёд по коду
|
||
2026-08-11 и с тех пор правятся урожаем прогонов.
|
||
|
||
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||
узлах».
|
||
|
||
### Типовые узлы
|
||
|
||
Рода узлов проекта и проверяемые свойства к каждому.
|
||
|
||
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||
`FindAndRunTranscribeCheckJob`):
|
||
|
||
- отличает «задач нет» от отказа и не считает первое ошибкой;
|
||
- при отказе на середине оставляет задачу в состоянии, из которого повтор
|
||
корректен, либо переводит в `failed` осознанно;
|
||
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
|
||
запись о новом файле создана;
|
||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||
- отвечает пользователю ровно один раз.
|
||
|
||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
||
|
||
- проверяет право отправителя до всякой работы;
|
||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||
- закрывает то, что открыл, на всех ветках выхода.
|
||
|
||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
||
|
||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||
- различает «сервис ответил отказом» и «сервис недоступен»;
|
||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||
успех молча.
|
||
|
||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||
подпакетом `migrations`):
|
||
|
||
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
|
||
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
|
||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||
держатель захвата;
|
||
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
||
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
||
|
||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||
`adapter/metaviewer/ffmpeg`):
|
||
|
||
- отсутствие программы в `PATH` отличается от отказа обработки;
|
||
- вход, пришедший от пользователя, не попадает в аргументы командной строки
|
||
неразобранным;
|
||
- пустой или частично записанный выходной файл считается отказом;
|
||
- процесс не висит вечно.
|
||
|
||
**Любой узел** — сверх свойств своего рода:
|
||
|
||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||
|
||
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
|
||
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
|
||
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
|
||
|
||
### Типовые ложноположительные
|
||
|
||
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
||
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
||
логирует его и не считает в метрику. Норма записана требованием
|
||
[pipeline](../openspec/specs/pipeline/spec.md).
|
||
|
||
**Оговорка, и она тут главная:** ложноположительным считается только само
|
||
молчание воркера. Проверка **формы** узнавания ложноположительной не является:
|
||
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
||
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
||
её как известную нельзя.
|
||
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
|
||
нет: три воркера читают три разных состояния, и одну строку они не делят.
|
||
Механика захвата и её слабые места — [database.md](database.md),
|
||
«Представление данных». Находка становится настоящей ровно тогда, когда
|
||
появится второй экземпляр процесса или второй воркер на то же состояние.
|
||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||
Новой находкой это не считается, пока не измерен рост.
|
||
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
|
||
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
|
||
разграничения по владельцу нет сознательно — [security.md](security.md),
|
||
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
|
||
поверхность, выставленная наружу, либо путь к содержимому записи **без**
|
||
сессии, а не повторение этого факта.
|
||
|
||
### Вопросы по темам
|
||
|
||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||
|
||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
||
дефектом «остановка хоронила запись»).
|
||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||
`s3.go`, `speechkit.go`, 2026-08-13).
|
||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||
2026-08-10).
|
||
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
|
||
URL с токеном бота (запрет в [security.md](security.md) и
|
||
[conventions/logging.md](conventions/logging.md)).
|
||
- `security`: не строится ли путь на диске или ключ объекта из значения,
|
||
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
|
||
(чтение `service/transcribe.go`, 2026-08-10).
|
||
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
||
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
||
журнала (журнал, запись 2026-08-11 про хвост имени).
|
||
- `architecture`: не появился ли второй путь приёма мимо
|
||
`createTranscribeJob` — сегодня через него идут оба входа
|
||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
|
||
первые две описаны частично. Поведение прочих узлов, включая
|
||
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
|
||
ещё — самый большой.
|
||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
||
(CLAUDE.md, «Инварианты»).
|
||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||
числа файлов здесь не называем — оно протухает с каждой задачей.
|
||
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||
2026-08-12 роняет гейт правилом линтера
|
||
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
|
||
спрашивать о нём не нужно.
|
||
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
|
||
собственная регистрация, вход по паролю, одноразовый код, восстановление
|
||
доступа, продление сессии. Всё это приходит включённым и закрывается нами
|
||
(задача `oidc-login` 2026-08-12).
|
||
- `security`: не появился ли второй способ получить сессию к тому же человеку —
|
||
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
|
||
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
|
||
после входа сервис к провайдеру не обращается, и канал здесь один
|
||
(ADR-2026-08-12-session-without-refresh).
|
||
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
|
||
приложения, — сборка роутера хранилища оказалась именно такой.
|
||
|
||
### Триггеры метки
|
||
|
||
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
|
||
|
||
**Крупное здесь** (поднимает до `large`, ось объёма):
|
||
|
||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||
колонку разом;
|
||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||
- смена модели очереди: захват, повторы и воркеры разом;
|
||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
||
|
||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||
|
||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||
пользователь приложения, до начала работы назвать нельзя;
|
||
- всё, что делается на выбранном фреймворке впервые: правила
|
||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||
собой — форма решения нащупывается по ходу;
|
||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||
до работы назвать нельзя;
|
||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||
форма решения зависит от замера;
|
||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
||
- всё, что требует записи в `research/` прежде, чем начать.
|
||
|
||
**Мелкое здесь** (опускает до `small`):
|
||
|
||
- правка текста, который видит пользователь Telegram;
|
||
- новая метрика в `internal/metrics`;
|
||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
||
- правка документов канона.
|
||
|
||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
|
||
ни были, они не `small`.
|
||
|
||
### Недоступно проверке
|
||
|
||
**Не проверит ни один проход:**
|
||
|
||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||
SpeechKit и Object Storage поднять в тесте нечем;
|
||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||
день, и утверждения о росте остаются условиями, а не замерами;
|
||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||
отдан внешней программе, и она вне нашей границы;
|
||
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||
выкладки вне репозитория, и по коду её не проверить
|
||
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||
- `security`: поведение браузера с куками — применение `SameSite`, приём
|
||
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||
этого рода остаются гипотезами.
|
||
|
||
**Перестали проверять сознательно:**
|
||
|
||
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
|
||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||
длительность от подставного источника. Своего теста у
|
||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
|
||
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
|
||
запускаться запрещено. Значит поведенческая верификация живым прогоном
|
||
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
|
||
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
|
||
нет.
|
||
|
||
## Журнал дефектов
|
||
|
||
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
|
||
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
|
||
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
|
||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||
оракул, и выдумывать оракул задним числом нельзя.
|
||
|
||
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||
|
||
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
|
||
же правка, что проложила контекст до `ffmpeg`
|
||
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
|
||
выкладка посреди конвертации переводит здоровую запись в терминальное
|
||
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
|
||
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
|
||
идёт дольше часа по построению
|
||
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
|
||
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
|
||
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
|
||
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
|
||
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
|
||
успешным, и метрика владельца — та, которой он замечает отказы, — не
|
||
шевелилась
|
||
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
|
||
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
|
||
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
|
||
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
|
||
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
|
||
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
|
||
видит — это заметили три прохода ревью независимо, и все три построили путь
|
||
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
|
||
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
|
||
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
|
||
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
|
||
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
|
||
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
|
||
|
||
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
|
||
|
||
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
|
||
`file.Link(c.bot.Token)`
|
||
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
|
||
построчно. Первый же сбой сети на скачивании писал в журнал
|
||
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
|
||
Telegram держит токен бота (`…/bot<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)
|