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,2 @@
schema: spec-driven
created: 2026-08-13
@@ -0,0 +1,207 @@
## Context
Сегодня старт роняет отсутствующий токен бота: сборка отправителя ответов
возвращает «токен не задан», и процесс заканчивается раньше, чем встаёт
HTTP-сервер. Соседний путь того же старта — сборка транспорта бота — тот же отказ
уже терпит и сервис не роняет. Два пути одного старта решают одно и то же
по-разному, и побеждает тот, что стоит выше.
Ограничение, из-за которого это дорого: боевым токеном запускаться запрещено, а
другого действующего токена у разработчика нет. Значит, живой прогон недоступен
никому, и всякая задача проверяется одними тестами.
Отправитель ответов уходит в ядро расшифровки обязательной зависимостью, и ядро
зовёт его без проверки. Убрать отправителя, ничего не решив, — значит уронить
процесс на первой же задаче из Telegram, лежащей в базе с прошлого запуска.
## Goals / Non-Goals
**Goals:**
- сервис поднимается без токена бота и работает оставшимся входом;
- отсутствие бота видно в журнале, а не выводится читателем из тишины;
- задача из Telegram, которой некому ответить, не роняет процесс и не теряется
молча;
- ошибка в токене остаётся заметной.
**Non-Goals:**
- приём из Telegram по существу — кто допущен, как забирается запись — не
нормируется; оговорка спеки `intake` остаётся;
- запрет запускаться боевым токеном не снимается и не смягчается;
- второй вход не становится необязательным «вообще»: сервис без обоих входов
бессмыслен, но проверять это изменение не берётся;
- отправка отложенных ответов, когда бот появится позже, не заводится.
## Decisions
### Решение 1: пустой токен — отказ от входа, негодный непустой — ошибка настройки
Разрез проходит по **пустому значению**, а не по отказу сборки бота.
Что человек увидит иначе: разработчик стирает токен в своём файле настроек и
поднимает сервис; владелец сервиса, опечатавшийся в токене при ротации, получает
отказ старта вместо сервиса, молча работающего без бота.
**Правка после ревью кода, решение владельца 2026-08-13.** Разрез перенесён с
«пусто / непусто» на «ответил ли Telegram»: недоступность Telegram на подъём
сервиса не влияет. Довод — тот же, что у паспорта: основной вход не Telegram, и
класть его целиком из-за чужой аварии нельзя. Ровно этого и требовал прежний
разрез: перезапуск в минуту аварии Bot API оставил бы без работы приём по HTTP,
панель и конвейер, которому Telegram не нужен вовсе.
Отдельно снят довод, оказавшийся ложным. Дизайн утверждал, что «Telegram не
признал бота» и «до Telegram не дошли» различать нечем. Различать есть чем:
ответ Bot API приезжает своим типом с кодом, транспортный отказ — нашим после
чистки, и одно от другого отделяется проверкой типа. Утверждение держалось на
незнании библиотеки, а не на её устройстве.
Из решения следует второе, без которого оно невыполнимо: **ожидание при сборке
ограничивается сроком**. Пока срока не было, недоступность не отличалась от
подъёма — молчащий Telegram вешал старт бессрочно, без записи, без порта и без
пробы здоровья. Срок стоит только на сборке; длинный опрос им не ограничен, и
клиент подменяется сразу после.
Рассмотрено и отвергнуто:
- **терпеть любой отказ сборки бота** — отвергнуто: опечатка в боевом токене
дала бы работающий сервис без бота, и отправители перестали бы получать
ответы. Ответ «такого бота нет» опознаётся точно, ждать по нему нечего, и он
остаётся единственным отказом старта;
- **ронять старт на любом отказе** — отвергнуто владельцем: авария третьей
стороны не должна класть основной вход;
- **отдельный ключ настройки «работать без Telegram»** — явное объявление
намерения. Отвергнуто: имя ключа конфига объявлено необратимым, а пустое
значение уже несёт ровно этот смысл. Второй способ сказать одно и то же
разъезжается — останется решить, что делать с пустым токеном при выключенном
ключе.
**Разрез стоит в одном месте, потому что клиент бота собирается один раз.**
Сегодня его собирают дважды — под отправителя ответов и под транспорт бота, — и
именно поэтому два пути разошлись. Вместо того чтобы согласовывать их вручную,
изменение сводит сборку к одной: клиент заводится в сборке при старте и отдаётся
обоим. Транспорт уже принимает готового клиента, так что менять надо только
отправителя — он перестаёт принимать токен и начинает принимать клиента.
Что это даёт сверх опрятности: разрез «пусто / непусто» существует ровно один,
запись о неподнятом боте по построению одна, обращение к Telegram при старте
одно вместо двух, и подмена журнала библиотеки тоже одна. Проверять «согласованы
ли два пути» больше не надо — второго пути нет.
**Цена решения:** сборка бота перестаёт терпеть негодный токен и начинает ронять
старт. Наблюдаемо это почти ничего не меняет: сборка отправителя роняет старт на
том же токене и сегодня, а стоит она раньше — до терпимости транспорта очередь
попросту не доходит.
### Решение 2: заглушка отвечает «канала нет», а запись делает шаг
Когда токена нет, ядро получает отправителя-заглушку. Она ничего не отправляет и
на всякий ответ возвращает **особое значение отказа — «канал доставки не
поднят»**. Шаг конвейера узнаёт это значение, пишет недоставку в журнал с
идентификатором задачи и завершается **без отказа**.
Что человек увидит иначе: владелец сервиса находит в журнале строку «ответ не
доставлен» с идентификатором задачи и забирает расшифровку там же, где лежат
остальные.
Почему запись делает шаг, а не сама заглушка: **идентификатора задачи у
заглушки нет**. Контракт отправки несёт текст, чат и сообщение для ответа —
задачу он не называет, и знать о ней отправителю незачем. Заглушка, пишущая
`chat_id` вместо задачи, дала бы владельцу строку, по которой задачу не найти, а
расширение контракта ради журнала потянуло бы правку и настоящего отправителя, и
всех его вызовов.
Почему это не заводит в ядре ветки «а есть ли бот»: ядро ветвится не на
устройстве сборки, а на **исходе доставки** — ровно так же, как оно уже ветвится
на «работы нет» и «захват потерян». Особое значение отказа живёт там же, где эти
два, и узнаётся тем же способом. Знания о том, как собран сервис, у ядра не
появляется.
Источник задачи ядро при этом уже различает: ответ отправителю начинается с
проверки источника и на задаче, пришедшей по HTTP, кончается раньше обращения к
отправителю. Заглушка задач основного входа не увидит, и ложных строк о
недоставке в журнале не будет.
Рассмотрено и отвергнуто:
- **ронять задачу в `failed`** — отвергнуто: расшифровка к этому моменту уже
получена и сохранена, а «не удалось» сообщить всё равно некому. Пометка отказа
на удавшейся работе врёт и панели, и метрике;
- **оставлять задачу пригодной к повтору** — отвергнуто: смысл повтора в том,
чтобы работа однажды удалась, а недоставка сама не пройдёт — бот не появится
оттого, что задачу подождали;
- **немая заглушка, возвращающая успех** — отвергнуто находкой ревью дизайна:
недоставку тогда некому записать, и норма «принятая запись не теряется молча»
оказывается нарушена именно тем решением, которое её и обслуживало;
- **отпустить отказ заглушки наверх, не разбирая** — отвергнуто: шаг объявил бы
отказ там, где работа сделана. Задачу это в повтор не отправит — воркеры
опрашивают только незавершённые состояния, — но воркеру засчитается сбой,
которого не было, и владельцу уедет запись отказа. Соврала бы и метрика, и
журнал.
### Решение 3: заглушка живёт рядом с настоящим отправителем
Место — тот же пакет, что и отправитель Telegram: заглушка знает ровно то же,
что и он, и подставляется в сборке при старте, как и все прочие адаптеры.
Направление зависимостей это не нарушает, и тесты-сканеры остаются зелёными.
Само значение отказа живёт среди контрактов — там же, где «работы нет» и «захват
потерян»: узнаёт его ядро, а порождает адаптер, и ни один из них не зависит от
другого.
**Форма значения — сентинел, а не тип с полями.** Соседи по ряду несут поле
(состояние, идентификатор задачи) и потому объявлены типами; этому нести нечего —
заглушка не знает ни задачи, ни чата. Прецедент сентинела в проекте есть: им же
объявлено «токен не задан».
**Заодно убирается третье представление того же факта.** Кроме пустой строки в
настройках и значения «токен не задан» в пакете отправителя, в транспорте бота
объявлен ещё один тип с тем же смыслом, не употребляемый нигде. Он снимается
этой же задачей: объяснять четвёртое представление дороже, чем удалить мёртвое.
### Решение 4: живой прогон требует заполнить ещё две секции, и это говорится вслух
Пустого токена мало. Настройки входа проверяются на старте и роняют процесс,
называя незаполненные ключи; конструкторы Yandex так же роняют его на пустых
регионе, ключах Object Storage, ключе SpeechKit и папке. Ни один из них при
старте наружу не ходит, поэтому **выдуманных непустых значений достаточно**
живой прогон получается, а денег не стоит.
Этой задачей разрез «пустое значение — отказ от возможности» на другие секции не
переносится: распознавание без Yandex не работает по существу, и отказ от него —
отдельное решение с отдельной ценой. Здесь только называется, что заполнить,
чтобы сервис поднялся.
Отсюда же граница правки документов: строка «живой прогон недоступен» не
снимается, а **сужается с остатком** — стал доступен подъём и осмотр, а прогон с
по-настоящему пустыми ключами Yandex по-прежнему невозможен.
## Risks / Trade-offs
- **Сервис молча работает без бота, потому что токен забыли стереть или забыли
вписать** → строка журнала при старте называет это прямо, а не оставляет
читателю вывод из тишины. Дальше — дело того, кто выкладывает;
- **Отказ старта на негодном токене останавливает выкладку, которая прежде
проходила** → это и есть цель решения 1; чинится правкой настройки, и отказ
называет, какой ключ виноват, не называя значения;
- **Сборка бота ходит в Telegram, и без сети старт с непустым токеном упадёт** →
поведение не новое и не ухудшается. Сегодня сборка отправителя зовётся первой и
роняет старт на любом отказе, так что терпимость соседнего пути на негодном
токене всё равно не срабатывает: до неё не доходит очередь. Изменение делает
два пути согласованными и **снимает** сеть с законного пути — пустой токен не
ходит наружу вовсе. Срока ожидания у обращения к Telegram при этом нет, и
задача его не заводит: таймауты у трёх внешних собеседников — известный
недостаток проекта и предмет отдельной работы;
- **Недоставленный ответ пропадает навсегда** → отложенной доставки нет и не
заводится: расшифровка лежит в хранилище и достаётся через панель и HTTP API;
- **Остановка идёт по пути, которым прежде не ходили** → останов зеркален
сборке: чего не собрали, того не закрывают и не ждут. Проверяется прогоном
сигнала остановки на конфиге с пустым токеном.
## Migration Plan
Схемы хранилища изменение не трогает, миграции нет, откат — обычный откат образа.
Выкладка с заполненным токеном ведёт себя ровно как прежде.
## Open Questions
Нет.
@@ -0,0 +1,54 @@
## Why
Сервис принимает записи двумя входами — ботом Telegram и HTTP API, — но
поднимается только тогда, когда настроены оба: пустой токен бота кончает старт
отказом раньше, чем встаёт HTTP-сервер. Боевым токеном запускаться запрещено, и
из этого следует, что **поднять сервис и посмотреть на него живьём не может
никто**: всякая задача, меняющая поведение, проверяется одними тестами.
Намерение «работать без Telegram» в сервисе уже есть — отдельное значение «токен
не задан» и терпимость к отказу сборки бота при старте, — но один путь его
отменяет, и потому оно ничего не значит.
## What Changes
- Ненастроенный вход Telegram больше не мешает подъёму: сервис встаёт и работает
оставшимся входом — принимает записи по HTTP, расшифровывает их и отдаёт текст
туда же. Об отсутствии бота сервис говорит одной строкой журнала при старте, а
не молчанием.
- Задача, пришедшая из Telegram и дошедшая до ответа тогда, когда бота нет,
доводится до конца, а факт недоставки уезжает в журнал владельца. Сегодня такая
задача уронила бы процесс.
- Запрет запускаться боевым токеном остаётся: рядом с ним появляется способ
поднять сервис без токена вовсе.
Ломки нет: с заданным токеном не меняется ничего.
## Capabilities
### New Capabilities
Новых нет: оба требования ложатся в capability, чей раздел `Purpose` сам
называет их своим предметом и приглашает дописать.
### Modified Capabilities
- `intake`: добавляется требование о подъёме с ненастроенным входом Telegram —
сервис работает оставшимся входом. Приём из Telegram по существу (кто допущен,
как скачивается запись) остаётся ненормированным, и оговорка спеки об этом
сохраняется;
- `pipeline`: добавляется требование об ответе отправителю, чей вход не поднят —
шаг не роняется, задача доводится до конца, недоставка идёт в журнал.
## Impact
- сборка сервиса при старте: отправитель ответов и клиент бота;
- ответ отправителю в конвейере расшифровки;
- секция `[telegram]` конфига и её образец `config.dist.toml`;
- `CLAUDE.md`, раздел «Запреты» — рядом с запретом на боевой токен встаёт способ
подняться без него;
- `docs/review.md`, подраздел «Недоступно проверке» — строка о недоступности
живого прогона сужается;
- `docs/architecture.md` — перечень capability и то, что каждая нормирует.
Внешних зависимостей, схемы хранилища и контракта HTTP API изменение не трогает.
@@ -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` что-то у себя, из отчёта нельзя.
@@ -0,0 +1,90 @@
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
токена.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и класть его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Токен не задан
- **GIVEN** в настройках сервиса токен бота пуст
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
он не поднят и почему
#### Scenario: Токен задан и годен
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения токена
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram отвечает отказом на этот токен
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения токена
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
@@ -0,0 +1,80 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке
@@ -0,0 +1,127 @@
## 1. Отправитель, который не отправляет
- [x] 1.1 Завести среди контрактов значение отказа «канал доставки не поднят» —
рядом с «работы нет» и «захват потерян», узнаваемое тем же способом
- [x] 1.2 Завести в пакете отправителя Telegram заглушку, реализующую контракт
отправки: она ничего не отправляет и на всякий ответ возвращает это
значение
- [x] 1.3 Проверить тестом, что заглушка возвращает именно его и ничего не пишет
сама
## 2. Ответ отправителю в конвейере
- [x] 2.1 Научить ответ отправителю узнавать это значение: пишется запись уровня
`WARN` с идентификатором задачи и причиной, шаг завершается без отказа
- [x] 2.2 Свести к тому же исходу вторую причину недоставки — задачу источника
Telegram без названного чата: сегодня она даёт отказ шага на уже
завершённой работе, то есть ложный сбой в счётчике воркера и перезапись
служебных полей
- [x] 2.3 Проверить, что в записи нет ни текста расшифровки, ни сообщения
отправителя
- [x] 2.4 Тест конвейера: задача источника Telegram доходит до ответа через
заглушку — шаг без отказа, состояние задачи не откатывается, в повтор она
не уходит и в `failed` не переводится
- [x] 2.5 Тест: задача источника Telegram без чата даёт тот же исход
- [x] 2.6 Тест: задача, принятая по HTTP, до заглушки не доходит и записи о
недоставке не порождает
## 3. Сборка при старте
- [x] 3.1 Свести сборку клиента бота к одной: отправитель ответов принимает
готового клиента вместо токена, транспорт получает того же
- [x] 3.2 Поставить разрез в этом единственном месте: пустой токен даёт заглушку
и одну запись уровня `WARN` о неподнятом боте, любой другой отказ сборки
роняет старт
- [x] 3.3 Тест на непустой токен, с которым бот не заводится: старт роняется.
Живой Telegram не нужен — адрес подставляется, как в имеющемся тесте
клиента
- [x] 3.4 Проверить, что ни запись о неподнятом боте, ни текст отказа старта не
несут значения токена
- [x] 3.5 Проверить остановку: сигнал остановки на конфиге с пустым токеном
завершает процесс тем же кодом и в тот же срок, что и с токеном
- [x] 3.6 Удалить неупотребляемый тип отказа «токен пуст» в транспорте бота —
третье представление того же факта
## 4. Настройки и их образец
- [x] 4.1 Описать в образце конфига, что пустой токен означает подъём без
Telegram и что при этом перестаёт работать
- [x] 4.2 Назвать там же остальные секции, без которых сервис не поднимется:
настройки входа и Yandex требуют непустых значений, при локальном прогоне
годятся выдуманные, наружу при старте не ходит ни одна
## 5. Проверки
- [x] 5.1 Тест на сборку отправителя с непустым токеном: прежний путь сохранён
- [x] 5.2 Живой прогон: конфиг с пустым токеном и заполненными по 4.2 секциями,
`GET /health` отвечает `200`, в выводе есть запись о неподнятом боте
- [x] 5.3 `task gate` зелёный
## 7. Развилки ревью кода — решения владельца 2026-08-13
- [x] 7.1 Недоступность Telegram на старт не влияет: разрез перенесён на «ответил
ли Telegram». Ответ «такого бота нет» роняет старт, всё прочее даёт подъём
без Telegram с записью `WARN`
- [x] 7.2 Ограничить ожидание при сборке клиента сроком — без него недоступность
неотличима от подъёма; длинный опрос сроком не ограничен
- [x] 7.3 Признак поднятости входов метрикой и счётчик недоставленных ответов с
причиной меткой
- [x] 7.4 Развести уровни недоставки: неподнятый вход — `WARN`, неназванный
адресат — `ERROR` (симптом порчи записи)
- [x] 7.5 Проверки на все четыре ветки сборки и на оба уровня недоставки
## 6. Документы
- [x] 6.1 `CLAUDE.md`, раздел «Запреты»: рядом с запретом на боевой токен встаёт
способ подняться без него
- [x] 6.2 `docs/review.md`, подраздел «Недоступно проверке»: строка о живом
прогоне сужается **с остатком** — подъём и осмотр стали доступны, прогон с
пустыми ключами Yandex по-прежнему нет
- [x] 6.3 `docs/architecture.md`: перечень capability отражает, что нормируют
`intake` и `pipeline` после этого изменения
- [x] 6.4 `docs/architecture.md`, таблица отказов внешних зависимостей: строка
про Telegram сегодня обещает дежурному «бот не стартует, приложение
продолжает работу без него» — привести к новому разрезу ссылкой на
требование, не перенося поведение в обзор
## Критерии приёмки
Первые три — дословно из записи задачи `local-run-without-telegram-token`.
**Четвёртый переписан** решением владельца на чекпоинте 2026-08-13: в прежней
редакции он требовал, чтобы задача осталась пригодной к повтору либо перешла в
`failed`, а дизайн отверг оба исхода с ценой, и норма `pipeline` требует прямо
обратного. Прежняя редакция сделала бы приёмку зелёной на поведении, которое это
же изменение запрещает. Запись задачи поправлена тем же решением.
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
отправителя с непустым токеном: прежний путь сохранён.
- Задача из Telegram, дошедшая до ответа при отсутствующем боте, не роняет
процесс и не теряется молча: она остаётся в достигнутом состоянии, в повтор не
уходит и в `failed` не переводится, а недоставка видна записью журнала с
идентификатором задачи. Оракул — тест конвейера с задачей источника Telegram и
заглушкой вместо отправителя.
- Задача источника Telegram без названного чата даёт тот же исход, а не отказ
шага. Оракул — тест конвейера на такой задаче: воркеру сбой не засчитан,
служебные поля завершённой задачи не переписаны.
Сверх записи задачи — из ревью дизайна:
- Непустой токен, с которым бот не заводится, роняет старт. Оракул — тест с
подставным адресом Bot API.
- Записей о неподнятом боте ровно одна. Оракул — живой прогон с пустым токеном:
отбор по журналу даёт одну строку, а не две.
- Клиент бота собирается в одном месте. Оракул — отправитель ответов принимает
клиента, а не токен, и `NewBot` зовётся из сборки при старте однажды.
Сверх ревью кода — решения владельца по трём развилкам:
- Недоступность Telegram подъёму не мешает, ответ «такого бота нет» роняет старт.
Оракул — проверки на четыре ветки сборки.
- Поднятость входов видна метрикой. Оракул — живой прогон с пустым токеном:
признак входа Telegram равен нулю, признак HTTP — единице.
- Неназванный адресат пишется уровнем `ERROR`, неподнятый вход — `WARN`. Оракул
— проверки конвейера на обе причины.
+83 -5
View File
@@ -4,12 +4,15 @@
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать.
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём по существу описан пока **только для HTTP** — того, что нормируют
проверки. Про вход Telegram нормировано одно: настроен он или нет и что из этого
следует для подъёма. Кто допущен к боту и как забирается присланная им запись,
требованиями по-прежнему не описано — требование, написанное без проверки, это
предположение, а не норма. Первая задача, которая трогает поведение приёма из
Telegram, дописывает его сюда.
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
требование, написанное без проверки, — предположение, а не норма. Первая задача,
которая трогает поведение приёма из Telegram, дописывает его сюда.
## Requirements
### Requirement: Приём записи по HTTP
@@ -256,3 +259,78 @@ Telegram делит с ним общий шаг заведения задачи,
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
### Requirement: Недоступный или незаданный вход Telegram не мешает подъёму
Сервис SHALL подниматься, когда вход Telegram поднять не удалось, и MUST
продолжать работу оставшимся входом: приём по HTTP, опрос готовности и конвейер
расшифровки работают в полном объёме. Неподнятый вход MUST быть назван в журнале
**ровно одной** записью уровня `WARN` при старте — с причиной и без значения
токена.
Исключение одно, и оно проходит по тому, **ответил ли Telegram**. Ответ «такого
бота нет» — ошибка настройки: бот по этому токену не появится ни от ожидания, ни
от повтора, и старт MUST кончаться отказом. Сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда.
Всё прочее — недоступность: сеть, DNS, авария Bot API, истёкший срок ожидания.
Она MUST не влиять на подъём. Основной вход сервиса — не Telegram, и ронять его
целиком из-за чужой аварии нельзя: перезапуск в такую минуту оставил бы без
работы и приём по HTTP, и панель, и конвейер, которому Telegram не нужен вовсе.
Ожидание при сборке MUST быть ограничено сроком. Без него недоступность
неотличима от подъёма: обращение к Telegram стоит на пути старта, и молчащий
собеседник останавливал бы его бессрочно — без записи, без порта и без пробы
здоровья.
Требование нормирует **наличие входа**, а не приём из него.
#### Scenario: Токен не задан
- **GIVEN** в настройках сервиса токен бота пуст
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** конвейер расшифровки работает
- **AND** бот не заведён, а в журнале ровно одна запись уровня `WARN` о том, что
он не поднят и почему
#### Scenario: Токен задан и годен
- **GIVEN** в настройках сервиса стоит токен, по которому Telegram признаёт бота
- **WHEN** сервис запускается
- **THEN** он поднимается и работает обоими входами
#### Scenario: Telegram не отвечает
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram недоступен либо не отвечает дольше отведённого срока
- **WHEN** сервис запускается
- **THEN** он поднимается и принимает записи по HTTP
- **AND** бот не заведён, а в журнале запись уровня `WARN` с причиной
- **AND** запись не несёт значения токена
#### Scenario: Telegram ответил, что такого бота нет
- **GIVEN** в настройках сервиса стоит непустой токен
- **AND** Telegram отвечает отказом на этот токен
- **WHEN** сервис запускается
- **THEN** старт кончается отказом
- **AND** ни журнал, ни текст отказа не несут значения токена
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
метрикой. Признак MUST выставляться при сборке входа и MUST различать поднятый
вход и неподнятый.
Требование стоит на том, что иначе потерянный вход не виден ничем: проба
здоровья отвечает «сервис работает» и при неподнятом боте, а запись журнала
живёт до ротации и вопрос «работает ли вход сейчас» не отвечает. Метрика —
единственный канал наблюдения, который у владельца автоматизирован.
#### Scenario: Вход Telegram не поднят
- **GIVEN** сервис поднялся без Telegram
- **WHEN** наблюдатель читает метрики
- **THEN** признак поднятости входа Telegram равен нулю
- **AND** признак поднятости входа HTTP равен единице
+73 -8
View File
@@ -3,16 +3,19 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
когда работы нет, что считается отказом шага и что бывает с ответом отправителю,
когда доставить его некуда.
Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число
попыток и состояние «мертва», нарастающая пауза перед повтором и условие записи
результата держателем захвата. Сознательно не описаны: цепочка переходов
`created → converted → transcribe → done | failed`, отмена контекста посреди
шага и освобождение ресурсов внешних клиентов. Это не значит, что такого
поведения нет: оно живёт в коде, а требования на него не написаны, потому что
требование без проверки — предположение, а не норма. Первая задача, которая
трогает любое из перечисленного, дописывает его сюда.
попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи
результата держателем захвата и недоставка ответа при неподнятом входе.
Сознательно не описаны: цепочка переходов `created → converted → transcribe →
done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
@@ -260,3 +263,65 @@ MUST расти с числом её попыток до объявленног
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
### Requirement: Недоставленный ответ не роняет шаг
Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ
отправителю доставить не удалось, и MUST не считать недоставку отказом шага.
Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор
задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной
меткой.
Причин у недоставки две, и исход у них общий: **вход отправителя не поднят**
задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат
у задачи не назван** — источником значится Telegram, а чата в задаче нет.
Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим,
и его уровень «может стать проблемой». Неназванный адресат — симптом порчи
записи: у задачи из Telegram чат есть всегда, и пропасть он может только от
дефекта, самый коварный источник которого назван инвариантом проекта про колонки
очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных
записей о ненастроенном боте.
Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как
достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный
отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть
соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от
ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор
не уходит и в `failed` не переводится, а причина недоставки живёт в записи
журнала, а не в состоянии задачи.
Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не
ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту
запись MUST не попадать — приватность содержимого записи требование не
ослабляет.
Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не
уходит и потом. Забрать расшифровку можно там же, где лежат остальные.
#### Scenario: Вход отправителя не поднят
- **GIVEN** задача принята входом Telegram прошлым запуском сервиса
- **AND** сервис поднялся без этого входа
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в
`failed` не переводится
- **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором
задачи и причиной
- **AND** счётчик недоставленных ответов вырос с этой причиной меткой
- **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет
#### Scenario: Адресат у задачи не назван
- **GIVEN** у задачи источником значится Telegram, а чат не назван
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем
- **AND** задача остаётся в достигнутом состоянии
- **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором
задачи и причиной: неназванный адресат — симптом порчи записи
#### Scenario: Отвечать некуда, потому что запись пришла не из Telegram
- **GIVEN** задача принята по HTTP
- **WHEN** шаг конвейера доходит до ответа отправителю
- **THEN** шаг завершается без отказа и без записи о недоставке