# Конфигурация

Конвенция: *как* устроена и грузится конфигурация transcriber (TOML).
Правила оформления кода (How), не спецификация поведения.

**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.

**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.

## Принципы

- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
  используем**: окружение наследуется дочерними процессами и видно через
  `/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
- Грузим **один раз при старте** в одну типизированную структуру `Config`
  (под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
  прикладном коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — перезапуск процесса.

## Файл и поиск

- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
  процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
  `config.toml` не коммитится.

## config.example.toml — самодокументируемый образец

`config.example.toml` коммитим как единый справочник по конфигу: все секции и
все поля. **Каждое поле снабжаем комментарием**, из которого ясно:

- **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы;
- **единицы измерения**, если применимы — секунды, байты, доля `0–1`.

```toml
[server]
port                   = <N>          # порт HTTP-сервера
shutdown_timeout       = <N>          # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N>          # ждать остановки воркеров, секунды
```

Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.example.toml` — источник истины по
составу полей.

Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).

*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.

Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
применённая порознь любая из них роняет старт либо оставляет сервис никого не
узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.

*Расхождение:* петлевые адреса в рецепте названы **парой** — `127.0.0.1` и
`::1`, — а не одним значением, хотя правило секции требует от образца только
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
входа.

## Поля по дискриминатору `type`

Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
бекендов или внешних сервисов), обязательность и опциональность полей определяет
значение `type`, а не фиксированный список секции.

- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
  обязательных полей; поля других значений не требуются. Неизвестное значение —
  ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.example.toml`:
  - основное (умолчательное) значение **предзаполнено** рабочими значениями;
  - альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
    полей (зачем, границы, единицы — как у обычных полей);
  - так из примера видны все варианты и поля каждого, не открывая код.

Дискриминатора в transcriber пока нет; правило записано на случай второго
распознавателя.

## Секреты

Секреты доставляет **выкладка**, рендеря их прямо в `config.toml` (transcriber:
Ansible из `pet-project-server`). Приложение просто читает TOML — отдельного слоя
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение.

- Секретные поля transcriber: `yandex.speech_kit_api_key`,
  `yandex.object_storage_access_key_id`,
  `yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
  2026-08-22 вместе с собственным входом.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
  владелец — пользователь процесса (`1000:1000`).
- В `config.example.toml` секретные поля — пустые строки.
  *Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
  строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
  отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
  собирает библиотека разбора, и собирает она его из разбираемого куска:
  `toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
  Оборванная кавычка в строке секретного ключа — типовая поломка криво
  отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
  инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
  разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
  без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
  неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
  текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
  платить не за что.

**Файл, способный нести секрет, называется в `.gitignore` и в
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
бы проверкой над проверкой, а такие проект не заводит
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
строку с одной стороны — вернуло её ревью.

## Проверка и остановка на старте

Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
и выход с ненулевым кодом, не стартуем наполовину.

Что проверяем:

- обязательные поля заданы;
- каталоги хранилища существуют и доступны на запись;
- границы числовых полей соблюдены;
- ключи внешних сервисов не пусты.

*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
выходит с кодом 1. Единого места проверки нет.

Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.

**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
HTTP.

Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
силе для прочих секций, где секреты есть.

## Структура в коде

- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям. Перечень
  секций и полей здесь не повторяем: источник истины по составу —
  `config.example.toml`, действующие числа — [../database.md](../database.md),
  «Настройки с числовым значением». Каталог данных задаётся одним ключом
  `[storage] data_dir`
  ([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
  требует правки обоих мест.
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
  такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
  тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
  комментария у самого поля), ни по нулевому значению типа; присутствие ключа
  судит **разбор** — `MetaData.IsDefined` из `toml.DecodeFile`, — потому что
  значение отличить «не задано» от «задано нулём» не позволяет. В
  `config.example.toml` у поля стоит значение свежей установки. Первым таким
  полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
  правила сейчас нет.
