Files
transcriber/openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
T
av cd57b68215 config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
2026-08-13 21:46:14 +03:00

241 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа:
`telegram.bot_token = ""` означает и «вход выключен намеренно», и «ключа нет».
Разрез объявлен решением владельца от 2026-08-13 и записан в
[ADR-2026-08-13-telegram-outage-does-not-block-startup](../../../docs/adr/ADR-2026-08-13-telegram-outage-does-not-block-startup.md);
здесь меняется не он, а то, **откуда** сервис узнаёт намерение владельца.
Ограничения, с которыми считаемся:
- файл настроек на сервере собирает Ansible из `pet-project-server`, и ключ
доступа приезжает туда из внешнего хранилища секретов. Значение, потерянное при
сборке, неотличимо от решения владельца;
- инвариант «секрет не покидает конфиг» — ни сообщение об отказе старта, ни
запись журнала не несут значения ключа. Проверка секции `[auth]` уже устроена
так и служит здесь образцом;
- локальный прогон боевым токеном запрещён, и подъём без Telegram — его обычный
режим. Он не должен стать труднее.
## Goals / Non-Goals
**Goals:**
- признак включения объявляет намерение, ключ доступа означает только доступ;
- включённый вход без ключа роняет старт с внятным сообщением;
- выключенный вход сообщается записью журнала, не поднимая уровень до
предупреждения;
- локальный прогон одним входом остаётся одной строкой настройки.
**Non-Goals:**
- приём записи из Telegram, белый список и доставка ответов;
- чистка прочих путей, где секрет мог бы уехать наружу: работа закрывает один
названный ревью — текст отказа разбора файла настроек;
- единое место проверки настроек для всех секций: `[auth]` и `[telegram]` пока
проверяются каждая своим методом, и сведение их в один проход — отдельная
работа;
- правка шаблона настроек в `pet-project-server`: его правит человек, здесь он
только назван.
## Decisions
### Признак обязателен, умолчания у него нет
Файл настроек без ключа `enabled` негоден: загрузка кончается отказом, и процесс
выходит с ошибкой настройки. Решение владельца от 2026-08-13.
Довод: умолчание — это угаданное намерение, а признак заводится ровно затем,
чтобы намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
стороны, и любое умолчание делает одну из двух ошибок тихой.
Альтернативы и причина отказа:
- **умолчание «включён»** — отвергнуто владельцем: файл без признака работал бы
«как-нибудь», и разница между объявленным и угаданным намерением исчезала бы
ровно там, где её завели;
- **умолчание «выключен»** — отвергнуто и по тому же доводу, и отдельно: первый
же подъём после выкладки выключил бы бота молча. Это исход, против которого
написано само требование.
Цена решения — порядок выкладки: шаблон настроек обязан получить признак раньше
образа. Она названа в разделе «Migration Plan» и на чекпоинте.
### Отсутствие ключа ловит загрузчик, пустой ключ — проверка секции
Разрез идёт по тому, **о чём судим**. Отсутствие ключа — свойство файла, и
видит его только разбор: `toml.DecodeFile` отдаёт `MetaData`, и `IsDefined`
отвечает, был ли ключ в файле вообще. Значение поля — свойство настройки, и
судит его `TelegramConfig.Validate()` по образцу `AuthConfig.Validate()`.
Альтернатива — сделать поле `*bool` и свести обе проверки в `Validate()`
отвергнута: указатель переживает проверку и уезжает к потребителям, где `nil`
уже невозможен, но выглядит возможным. Читатель настройки платит за форму,
нужную одному разбору.
`MetaData` из `LoadConfig` наружу не отдаётся: отказ формируется на месте, и
знание о разборе не растекается.
### Проверка ключа живёт в настройках, а не в сборке входа
`TelegramConfig.Validate()` зовётся из `main.go` сразу после загрузки, рядом с
проверкой секции `[auth]`, роняет процесс, называет **имя** незаполненного ключа
и не касается значения.
Альтернатива — оставить проверку внутри сборки клиента, как сейчас, — отвергнута:
сборка ходит в сеть, и отказ настройки смешался бы там с отказом Telegram. Читать
разрез пришлось бы по типу ошибки, а не по месту.
### Ветка «токен пуст» в разборе сборки меняет исход
Сегодня `telegramFromBot` на `telegram.ErrEmptyToken` отдаёт мягкий исход: сервис
поднимается без Telegram. После разведения это состояние по построению
недостижимо — проверка настроек ловит его раньше, — но ветку не убираем: она
получает исход «ошибка настройки, старт роняется» и встаёт рядом с отказом Bot
API.
Причина: удалённая ветка оставила бы пустой ключ падать в общий случай `err !=
nil`, то есть в «недоступность», и обход проверки настроек дал бы тихий подъём —
ровно то, что мы убираем. Ветка, недостижимая по построению, но дающая верный
исход, дешевле ветки, дающей неверный.
Единая точка `telegram.NewBot` и значение `telegram.ErrEmptyToken` остаются как
есть: они держат инвариант «Bot API только через нашего клиента».
### Отказ разбора файла настроек говорит своими словами
Найдено ревью дизайна и чинится этой же работой по решению владельца.
`toml.DecodeFile` отдаёт отказы двух семейств, и значения несёт **только одно**:
- `toml.ParseError` — сюда сведены отказы лексера и разбора значения, и его поле
`Message` собирается из разбираемого куска (`Invalid float value: %q`,
`invalid duration: %q`, `%v is out of range`). Незакавыченный токен из криво
собранного шаблона выкладки попадает в текст целиком. Из этого отказа берём
**строку, столбец и последний ключ** — они безопасны, — а `Message` не берём;
- прочие отказы декодера (несовпадение типов, неподдерживаемый тип) собираются
из **имён ключей и имён типов**, значений в них нет. Их текст берём как есть:
выбрасывать его значило бы платить разборчивостью отказа там, где платить не за
что.
Отвергнутые альтернативы:
- **выбросить текст обоих семейств** — просто и закрыто наглухо, но за
несовпадение типов (`port = "8080"`) владелец получал бы «файл не
разбирается» без единого намёка, а значения там нет по построению;
- **вычищать значения из текста** — вычищать не с чем: разбор не состоялся, и
значений в настройках ещё нет;
- **брать `Message`, когда последний ключ не секретный** — перечень секретных
ключей живёт в конвенции и разошёлся бы с кодом молча, а расхождение здесь
означает утечку.
**Спеки это не меняет, и требования под себя не заводит.** Норма уже записана и
сильнее спеки: инвариант «Секрет не покидает конфиг» в `CLAUDE.md` со степенью
`critical`. Работа приводит код в соответствие с записанным, а не заказывает
новое поведение. Форма записи отказа уезжает в конвенцию настроек, раздел
«Секреты», — там её дом.
**Дом нормы назначен явно, и это выбор, а не умолчание.** Загрузка настроек не
принадлежит ни одной заведённой capability: `intake` сама объявляет, что нормирует
наличие входа, а не приём; `access`, `pipeline` и `storage` к разбору файла
отношения не имеют. Заводить capability подъёма ради одного семейства отказов
дороже выигрыша, а вписывать разбор настроек в `intake` значит переносить туда
чужое. Поэтому дом нормы — **инвариант `CLAUDE.md` плюс конвенция
`docs/conventions/config.md`**, и спеки загрузку настроек не нормируют.
Найдено ревью кода; цена решения в том, что при следующей ревизии семейства
отказов спека не скажет ничего и опорой будут конвенция и проверки.
### Выключенный вход — уровень `INFO`
Предупреждение говорит «случилось не то, что ты просил». Выключенный вход — ровно
то, что просил владелец, и на каждом локальном прогоне это давало бы шум,
неотличимый от настоящей недоступности. Недоступность остаётся `WARN`.
Признак поднятости входа (`IntakeUpGauge`) выставляется во всех случаях, включая
выключенный: наблюдателю нужен ответ «работает ли вход сейчас», а не «почему».
### Сборка входа получает настройки секцией, а решение о выключенном входе — шов
`buildTelegram` принимает `config.TelegramConfig` целиком вместо одного токена:
решение «поднимать или нет» читает оба поля, и разносить их по двум аргументам
значит заводить два места, где их сверяют.
Само решение уезжает в `telegramFromConfig(cfg, newBot, logger)`, где `newBot`
параметр-функция сборки клиента; `buildTelegram` подставляет туда
`telegram.NewBot`. Иначе главное утверждение выключенного входа — **обращения к
Telegram не уходит ни одного** — проверить нечем: `telegram.NewBot` держит адрес
Bot API внутри, и проверка, судящая по исходу, останется зелёной и тогда, когда
ветка выключенного входа встанет **после** обращения. Тогда прогон с заполненным
ключом ходил бы в живой Telegram боевым токеном, а проверка этого не заметила бы.
Шов — параметр-функция, а не интерфейс: реализация у него одна, и вводить ради
неё тип значит заводить понятие там, где хватает подписи. Прецедент в проекте
свой и того же рода — `telegram.newBot(token, endpoint, logger)` принимает адрес
отдельно ровно затем, чтобы проверка не ходила в сеть.
## Risks / Trade-offs
- **Файл настроек на сервере отстал от кода** → сервис не поднимется вовсе:
признака в файле нет, загрузка кончается отказом. Это главный риск работы, и
снимается он порядком выкладки — сперва шаблон настроек, потом образ. Отказ
громкий, называет ключ и виден в первую же минуту; молчаливая потеря бота
обошлась бы дороже, но порядок соблюсти обязан человек.
- **Локальный файл настроек отстал от кода** → тот же отказ и та же починка:
одна строка `enabled = false`.
- **Проверок настроек стало две вместо одной** → расхождение между ними ловится
только глазами. Сведение в один проход названо Non-Goal и остаётся работой на
потом.
- **Ошибочный `enabled = false` из шаблона выкладки** → работа закрывает одно
русло молчаливой потери бота (потерян ключ доступа) и оставляет второе:
признак, отрендеренный ложным из-за пропущенной переменной, отличим от решения
владельца **только записью журнала**`INFO` против `WARN`. Признак
поднятости входа тут не помощник: он равен нулю и при выключенном входе, и при
недоступности Telegram, то есть от аварии этот случай не отделяет, а
собственного оповещения у проекта нет вовсе. Сервис поднимается штатно, и
владелец узнаёт о беде от молчащего бота — тем же способом, что и прежде.
Ненаписанный риск читается как несуществующий, поэтому он назван здесь: ключ
`enabled` в шаблоне выкладки критический.
- **Остаточный риск утечки при смене версии библиотеки разбора** → разрез ниже
опирается на то, какие семейства отказов несут значения сегодня. Версия
библиотеки, переложившая значение в другое семейство, вернёт утечку молча.
Держится это проверкой на поломанной строке секретного ключа; она же краснеет
при таком переносе.
- **Ветка, недостижимая по построению**, живёт в коде и её нельзя проверить
через настройки → проверяется напрямую на уровне разбора исхода сборки, как
уже устроены соседние ветки.
## Migration Plan
Порядок обязателен, и нарушение его роняет сервис на сервере.
1. Код и образец настроек едут вместе: `config.dist.toml` получает
`enabled = false` при пустом ключе доступа — это состояние свежей локальной
установки.
2. **Раньше накатки образа** шаблон настроек в `pet-project-server` получает
строку `enabled = true`, и файл на сервере перерисовывается. Правит человек,
отдельно от этой работы; пока правки нет, новый образ на сервер не едет.
3. Только после этого едет образ.
Откат: вернуть прежний образ. Файл настроек с ключом `enabled` прежний код
разбирает без отказа — лишний ключ TOML разбор не роняет, он просто не читается,
и бот поднимается по непустому токену.
**Откат при выключенном входе допустим только на образ от 2026-08-13 и новее.**
На более старом состояния «сервис поднят, бот опущен» не существует вовсе:
пустой ключ роняет старт, негодный роняет старт, годный поднимает бота. Откат
туда делают с непустым годным ключом, приняв, что бот поднимется; рецепт ниже на
таком образе ведёт к выходу с кодом 1 до открытия порта.
**Один случай отката требует и отката настроек**`enabled = false` при
заполненном ключе доступа, то самое состояние, ради которого два значения и
разводятся. Прежний код признака не видит и поднимает бота, то есть отменяет
решение владельца молча. Если вход был выключен потому, что бот с этим токеном
поднят где-то ещё, два процесса поделят один длинный опрос и часть ответов до
людей не дойдёт — прямо тот вред, который называет запрет «Боевым токеном бота не
запускаться». Откат в этом состоянии начинается с очистки ключа доступа.
## Open Questions
Открытых нет: умолчание признака решено владельцем 2026-08-13 — признак
обязателен, умолчания у него нет.