telegram: сервис поднимается без бота и работает одним входом

- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
This commit is contained in:
av
2026-08-13 19:10:08 +03:00
parent 863ba3b42e
commit b733a84d6a
33 changed files with 1579 additions and 91 deletions
@@ -0,0 +1,172 @@
# Ревью изменения `start-without-telegram-token` — отчёт триажа
Прогон 2026-08-13. Отчёт сохранён оркестратором: агент триажа записывать
`.md` не вправе.
## Сводка
- **Режим:** по графу; изменение не закоммичено, база диффа `origin/master`.
- **Метка:** `large` — крупное × знакомое. Повторная разметка после правок
дизайна: первая давала `medium`, исходя из того, что ядро не тронуто; правки
ревью дизайна это допущение сняли.
- **Гейт:** зелёный целиком, 13 шагов, включая `-race`, `golangci-lint`,
`govulncheck`. Оракул снят проходом `autotests`, триаж гейт не перезапускал.
- **Находок на входе:** 19 (specs 3, code 6, architecture 3, adversary 4,
ops 3, autotests 0). **Осталось:** 6 в основном списке, 2 гипотезы,
2 кандидата в промоут; срезы названы поимённо.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs` + дельты change | разбор | specs | закрыта, 3 находки |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 0 находок |
| conventions | `docs/conventions/` | разбор | code | закрыта, 6 находок |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки |
| security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки |
Тем без дома нет, тем без отчёта нет. Своих тем у проекта нет, `basics` не
запускался — все темы ядра закрыты именными проходами. Побочное следствие:
независимого второго голоса о заниженности метки на прогоне не было.
## Блокирует мердж
### 1. Токен бота уезжает в журнал целиком при опечатке — critical
`internal/adapter/telegram/bot.go` возвращал отказ конструктора без чистки.
Отказ рождается в `http.NewRequest` на разборе адреса — **до** обращения к
клиенту, то есть мимо `safeClient` и `WithoutURL`. Токен с управляющим символом
или неверной `%`-последовательностью печатался в журнал целиком.
Оракул: воспроизведено тремя проходами независимо и триажем отдельно. Нарушены
инвариант `CLAUDE.md` «Секрет не покидает конфиг» (critical) и MUST дельта-спеки
`intake`.
**Исход: починено инлайн.** Отказ конструктора пропущен через `WithoutURL`.
Заодно закрыта дыра в собственной проверке: прежний тест судил запрет **годным**
токеном, то есть случаем, который и так работал. Добавлен тест с токеном,
ломающим разбор адреса.
### 2. Молчащий Telegram вешает старт навсегда — major
Клиент собран из `&http.Client{}` без срока ожидания, сборка стоит до подъёма
сервера. При Telegram, отвечающем молчанием, процесс висит бесконечно: порт не
слушается, `/health` не отвечает, воркеры не запущены, в журнале ни строки.
Поведение предсуществует изменению, но изменение **записывает его нормой**.
Довод дизайна «различать нечем» проверяемо неверен: отказ Bot API приезжает
типом `*tgbotapi.Error` с кодом, транспортный — нашим после чистки.
**Исход: развилка владельцу.** Цена дописана в таблицу отказов
`docs/architecture.md`; выбор поведения — за владельцем.
## Стоит исправить сейчас
### 3. Сервис без Telegram выглядит здоровым — major
`/health` отдаёт статические `200 ok` и о входах не знает; серий `transcriber_*`
на `/metrics` при неподнятом боте ноль; поля «доставлено» в схеме нет. Владелец
узнаёт о потерянном входе только из журнала контейнера и только до ротации.
**Исход: развилка владельцу.** Попутно исправлено фактическое: обоснование нормы
называло третьим последствием перезапись служебных полей завершённой задачи —
такого не бывает, переход в терминальное состояние снимает захват, и повторная
запись натыкается на «захват потерян». Довод сведён к двум последствиям.
### 4. Задача из Telegram, потерявшая чат, считается успешной — major
Было `ERROR` и отказ шага, стало `WARN` и успех. Тем самым снят самый громкий
детектор класса, который `CLAUDE.md` называет самым коварным: колонка, выпавшая
из пары `acquireColumns`/`acquiredRow`, обнуляет чат у задачи, попавшей к
воркеру.
**Исход: развилка владельцу** — развести уровни по причине или оставить.
### 5. Разрез старта не держался ни одним тестом — minor
Инвертируй разрез — весь набор оставался зелёным.
**Исход: починено инлайн.** Решение вынесено из `main` в `telegramFromBot` и
накрыто тремя случаями. Обращение к Telegram отделено от решения намеренно:
обращение ходит в сеть и в проверке недоступно, а разрез проверять надо.
### 6. Образец конфига и три документа описывали снятое поведение — minor
`config.dist.toml` оставлял непустой плейсхолдер, хотя собственный комментарий
рядом объявлял пустой токен режимом: копия образца старт роняла.
`docs/conventions/config.md` описывал снятый механизм строкой «Расхождение», а
она в этом проекте выдаёт индульгенцию будущим ревью. `docs/conventions/errors.md`
перечислял удалённый тип. Маркер канона в `docs/architecture.md` ссылался на
несуществующие capability.
**Исход: починено инлайн, все четыре места.**
## Чем кончились развилки — решения владельца 2026-08-13
- **Находка 2 (молчащий Telegram).** Выбран вариант сверх предложенных:
недоступность Telegram на старт не влияет. Разрез перенесён с «пусто /
непусто» на «ответил ли Telegram»: ответ «такого бота нет» роняет старт,
недоступность даёт подъём без Telegram с записью `WARN`. Из решения следует
срок ожидания при сборке — без него недоступность неотличима от подъёма.
- **Находка 3 (наблюдаемость).** Выбран счётчик и признак входов: метрика
поднятости по каждому входу и счётчик недоставленных ответов с причиной
меткой. Колонку в задаче не заводили — это шаг схемы и необратимое.
- **Находка 4 (уровень).** Уровни разведены: неподнятый вход — `WARN`,
неназванный адресат — `ERROR`.
**Отдельно о самом прогоне.** Живой прогон, снятый проходом `adversary`, оставил
процесс работающим на том же порту, и он держал его ещё час. Часть моих проверок
после переделки мерила этот чужой процесс, а не новую сборку; обнаружено по
отсутствию новой метрики, исправлено остановкой процесса и повторным прогоном.
Кандидат в правило: прогон, поднимающий сервис, обязан снимать его за собой, а
проверяющий — убеждаться, что порт занят его собственной сборкой.
## Гипотезы без доказательства
- **`{"ok":true,"result":null}` считается успешной доставкой.** Механизм доказан
на подставном сервере, вторая половина — что живой Telegram так отвечает — не
доказана и по правилам проекта недоказуема. Предсуществует изменению.
- **Второй `os.Exit(1)` недостижим сегодня.** Приемлемая страховка, не дефект:
конструктор объявляет отказ в сигнатуре, и разобрать его вызывающий обязан.
## Кандидаты в промоут
- **Порядок выкладки: конфиг с пустым токеном нельзя выкатывать раньше бинаря.**
Воспроизведено на `origin/master` в отдельном worktree: откат бинаря при уже
применённом пустом токене останавливает весь сервис. Это правило эксплуатации,
которого в проекте нет; дом — `docs/architecture.md`, «Эксплуатация».
- **Сверка документов на упоминания удалённых идентификаторов.** Три из четырёх
мест находки 6 — прямые ссылки на снесённый код и несуществующие capability.
Ловит это `av-dev:doc-healthcheck`, которого зовут руками.
## Границы покрытия
**Что не проверил ни один проход** (`docs/review.md`, «Недоступно проверке»):
поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки;
стойкость `ffmpeg` к вредоносному входу; поведение настоящей Authelia; поведение
браузера с куками.
**Перестали проверять сознательно:** разбор вывода настоящего `ffprobe`; работа
сервиса с настоящими внешними собеседниками. Подъём живьём стал доступен как раз
этим изменением, но остаток — приём из Telegram, расшифровка, заливка — не
проверяет никто.
**Чего не принесёт ни один прогон:**
1. Решения проекта не сверялись — `docs/adr/` процессный, прогон его не
открывает. Расхождение с записанным решением ловит `av-dev:doc-healthcheck`.
2. Записанные наблюдения не использовались — `docs/research/` тоже процессный.
Всякое число этого отчёта снято на этом прогоне.
3. Поимённой сверки с руководствами по стилю Go не задавал ни один проход.
4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.
**Сработавшие потолки.** Потолок триажа: 19 находок → 6. Срезано поимённо:
дубли в тестах (близко к вкусовщине; починено попутно, тот же файл правился
находкой 1); избыточность представлений факта «Telegram не поднят» — шесть
вместо четырёх, одно сократимо, последствие не названо; откат бинаря — уехал в
промоут; вырожденный ответ библиотеки — в гипотезы.
**Отдельная находка о самом прогоне:** проходы отдавали сводки пересказом, и
свои блоки «Coverage of this pass» с потолками до триажа дошли не все — узнать,
срезал ли `code` или `adversary` что-то у себя, из отчёта нельзя.