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

20 KiB
Raw Blame History

Context

Разрез «поднимать ли вход Telegram» сегодня проходит по пустоте ключа доступа: telegram.bot_token = "" означает и «вход выключен намеренно», и «ключа нет». Разрез объявлен решением владельца от 2026-08-13 и записан в ADR-2026-08-13-telegram-outage-does-not-block-startup; здесь меняется не он, а то, откуда сервис узнаёт намерение владельца.

Ограничения, с которыми считаемся:

  • файл настроек на сервере собирает 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 — признак обязателен, умолчания у него нет.