docs: правила о контексте и о токене записаны, две находки — в журнал

- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень
  механизированного пополнен девятью правилами и двумя шагами гейта, названы
  остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations
  судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck
  профилактические — предмета в коде нет
- в logging.md и security.md чистка отказа Telegram описана по факту: точка одна
  и лежит на границе клиента, закрыты все пять путей вместе с логгером самой
  библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было
  неверным дважды
- в журнал дефектов записаны две находки: отказ скачивания уносил токен бота
  (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую
  запись в failed (поймано ревью до коммита)
- вопрос темы operations про отмену переформулирован: спрашивать надо не
  «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом
  отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста
  на него не отвечает
- в памятке: словарь кодов новых шагов, требование компилятора C у детектора
  гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
This commit is contained in:
av
2026-08-13 10:28:31 +03:00
parent bc5c35790e
commit bb9a67929c
6 changed files with 158 additions and 19 deletions
+10 -4
View File
@@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек ## Стек
Go 1.26 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
@@ -87,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
```bash ```bash
go build ./... # CGO не нужен go build ./... # CGO не нужен
go test ./... go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./... go vet ./...
gofmt -l . gofmt -l .
golangci-lint run golangci-lint run
@@ -117,13 +117,19 @@ task gate # весь набор проверок разом
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
`shell`, `dockerfile` и `vulns`: недостающий инструмент — отказ окружения, код `tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
3. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`, — отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой». `golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
любой отказ шага выходит с 201, а код шага печатает строкой любой отказ шага выходит с 201, а код шага печатает строкой
(«exit status 3»), поэтому словарь читается по коду скрипта. («exit status 3»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
схемы,
неотформатированный файл, находка `golangci-lint`, расхождение объявленных неотформатированный файл, находка `golangci-lint`, расхождение объявленных
версий Go, дрейф раскладки документов, версий Go, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
+7 -3
View File
@@ -93,8 +93,11 @@
## Внешние границы и форматы ## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения. - **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
отдаёт файлы больше 20 МиБ — это потолок приёма из бота. самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -118,8 +121,9 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
+36 -1
View File
@@ -40,7 +40,11 @@ Go-проект как есть. Своё здесь — перечень пра
версий Go поведение нормировано отдельно, спекой версий Go поведение нормировано отдельно, спекой
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка [toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
проекта, у которой есть своя capability, и потому единственная, чьи сценарии проекта, у которой есть своя capability, и потому единственная, чьи сценарии
проверяются построчно (`scripts/check_go_version_test.go`). проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
объявленный долг, а не умолчание.
## Лестница механизации ## Лестница механизации
@@ -104,6 +108,9 @@ Go-проект как есть. Своё здесь — перечень пра
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` | | Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` | | Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` | | Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы ### Структура и границы
@@ -115,6 +122,14 @@ Go-проект как есть. Своё здесь — перечень пра
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда | | Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
### Отмена и внешний собеседник
| Правило | Где механизировано |
| --- | --- |
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml``noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml``contextcheck` |
| Тело ответа HTTP закрывается | `.golangci.yml``bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
### Время, вывод, конфигурация ### Время, вывод, конфигурация
| Правило | Где механизировано | | Правило | Где механизировано |
@@ -130,6 +145,9 @@ Go-проект как есть. Своё здесь — перечень пра
| --- | --- | | --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть | | Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go ### Форма кода и файлов вне Go
@@ -146,6 +164,7 @@ Go-проект как есть. Своё здесь — перечень пра
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` | | Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` | | Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` | | Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
@@ -166,6 +185,7 @@ Go-проект как есть. Своё здесь — перечень пра
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок | | Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе | | `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре | | Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` | | `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном | | `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели | | `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
@@ -185,6 +205,15 @@ Go-проект как есть. Своё здесь — перечень пра
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`: - вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу; `forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`; - проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
`context.Background()` там, где контекст был доводом, — но метод, у которого
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
остаётся человеку;
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
добавленного после неё файла статус `A`, и правка такого файла законна — он
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — - направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и `controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
есть роутер этого хранилища. Изъятие названо в есть роутер этого хранилища. Изъятие названо в
@@ -223,5 +252,11 @@ Go-проект как есть. Своё здесь — перечень пра
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму, они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
2026-08-13 про обходимый текстовый запрет). 2026-08-13 про обходимый текстовый запрет).
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
мутация, задевшая сразу два правила, покажет только первое: так молчали
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её 5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
заменило. заменило.
+16 -4
View File
@@ -241,10 +241,22 @@ Object Storage, скачивание файла из Telegram и опрос оп
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня `internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
она не логируется — то есть утечки нет, но защищает от неё только отсутствие Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
строки лога.
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
`sendMessage`, скачивание записи и `getMe` из конструктора;
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
замена точная — токен известен.
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
отказа и строке журнала.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
+79 -5
View File
@@ -116,12 +116,18 @@
Форма: `<тема>: <вопрос> (<провенанс>)`. Форма: `<тема>: <вопрос> (<провенанс>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры - `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и внешнего собеседника и это держат правила `noctx` и `contextcheck`
`transcribe.go`, 2026-08-10). ([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 — ни у - `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`, одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
2026-08-10). контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`, возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10). 2026-08-10).
@@ -240,6 +246,74 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя. выдумывать его задним числом нельзя.
## 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 — конец потока распознавания узнавался по тексту сообщения [пойман сканером] ## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока - **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
+10 -2
View File
@@ -267,8 +267,16 @@ Telegram отправителю.
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт. типа файла не идёт.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
нигде не логируется. `…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
## Что вне модели ## Что вне модели