## Context

Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками
`Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда
соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена
заголовков объявлены константами транспорта в
`internal/controller/http/identity.go`, и там же записано, что настройкой их не
делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой
`access`.

На машине разработчика прокси нет, браузер заголовков не ставит, и приложение
локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси
`go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий
три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в
два процесса, приложение открывают по адресу прокси, а перечень доверенных
адресов в конфиге приходится переставлять на петлевой.

Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Изменение переносит подстановку заголовков в сам сервис и закрывает её
предохранителем.

Ограничение, из которого читается всё остальное: **подстановка заголовков — это
настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий,
до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из
того, что подстановке запрещено.

## Goals / Non-Goals

**Goals:**

- Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
  назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
  настроек, и ничем больше.
- Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
  неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
- Настройка, при которой отладочный вход становится открытым входом, роняет
  старт, а не работает молча.
- Отладочный запуск виден владельцу в журнале.

**Non-Goals:**

- Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя,
  снятие ограничителя частоты, послабления проверок конфига — ничего этого новый
  признак не включает (см. решение 7).
- Своего входа, ролей и проверки допуска сервис по-прежнему не заводит:
  подстановка называет пришедшего, а не судит его.
- Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают
  ставить заголовок сами и в подстановке не нуждаются.
- Граница работы — заголовки входа. Подмена распознавания в конфиг не
  переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой
  кода, и решением владельца так и остаётся.

## Decisions

### 1. Где подставляются заголовки: отдельный слой перед узнаванием

**Решено:** отдельный слой цепочки корня приложения, стоящий **перед**
`TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
запроса и ничего больше не делает: учётной записи не заводит, отказов не
выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от
пришедшего с сервера.

Слой собирается только при включённом предохранителе и непустой имитации. При
выключенном — цепочка та же, что сегодня, без единого лишнего звена.

Рассмотрено и отвергнуто:

- **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй
  источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка,
  а её отладочный двойник, и это ровно то, чего изменение обязано избежать.
- **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения
  ставятся на запрос, а не на сервис.
- **Слой снаружи ограничителя частоты** — ограничитель считает по адресу
  спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит
  трогать порядок, который сегодня верен, без единой причины.

Что человек увидит иначе: приложение открывается по адресу сервиса, второго
порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере.

### 2. Область слоя: только корень приложения

Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и
область его выводится из того же перечня адресов, а не перечисляется вторым
списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там
учётной записи нет и не нужно.

### 3. `debug = false` при непустой имитации: отказ старта

**Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое
игнорирование.

Причина: состояние «имитация заполнена, предохранитель выключен» не читается
никак, и обе его прочтения — поломка.

- Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и
  человек будет искать поломку во входе, в прокси и в перечне адресов — везде,
  кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал
  локального запуска читают тогда, когда уже что-то сломалось.
- Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг,
  которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот
  случай, когда поломку ловят на порядок раньше, чем она стоит денег.

Рассмотрено и отвергнуто:

- **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая
  запись не теряется молча» описывает ту же болезнь на соседнем предмете.
- **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле
  жить сколько угодно.

Обратная сторона: `debug = true` при пустой имитации отказом **не** считается.
Это законный запуск — признак сам по себе ничего не включает.

### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку

Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса,
называет себя кем угодно и получает чужой архив. Подстановка делает это хуже
барьера `trusted_proxies` по двум причинам. Первая: подставляется по
**отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот,
который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy
«заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе,
потому что перезаписывать нечего.

**Решено — одно ограничение: подставленный заголовок проходит тот же барьер,
что и настоящий.** Слой подставляет значения только тогда, когда адрес
соединения попадает в
`trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание:
разбор адреса пира и сверка с перечнем берутся из одного дома
(`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же,
как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с
первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший
свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится.

Это не предохранитель, а согласованность с барьером узнавания: от конфига она не
требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и
так очерчен перечнем.

**Адресного предохранителя не делаем.** Решение владельца на чекпоинте,
дословно: «предохранитель по адресам не делаем, полагаемся только на параметр
debug». Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.

Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира
там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный
прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи
добавляются к тем, что в нём уже стоят.

Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а
доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся
три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при
заполненной имитации без предохранителя (решение 3); боевой конфиг, который
рендерится шаблоном Ansible, а не копируется с машины разработчика.

Что человек увидит иначе: отладочный вход работает везде, где адрес соединения
попадает в перечень доверенных, — и на машине разработчика, и внутри
контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не
признаком отладки.

Рассмотрено и отвергнуто:

- **Требовать петлевой перечень при включённом предохранителе** — снято
  решением владельца, причина выше. Держалось предикатом «петлевая запись»,
  который заводился ради этой одной проверки.
- **Принудительно слушать петлевой адрес при включённом предохранителе** —
  меняет поведение молча (адрес прослушивания задан числом порта, и человек
  увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает:
  внутри контейнера сервис слушает не петлю.
- **Новый ключ `[server] listen`** — публичная поверхность настроек ради
  предохранителя, которого решением владельца нет.
- **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте
  не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени,
  дал бы ровно ту тишину, которой избегает пункт 3.

### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User`

**Решено:** решение принимает **наличие `Remote-User`**, а не его значение.
Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт
его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку
`Remote-*`, которой распоряжается: названные имитацией — значением из конфига,
не названные — удаляет.

Почему по наличию, а не по значению: узнавание отвергает запрос с более чем
одним `Remote-User`, и это его главная защита от прокси, который заголовок
добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы
второе значение и превратил бы законный отказ в отладочный проход.

Почему удаляем не названные имитацией: запрос без `Remote-User`, но с
`Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин
свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе.

Рассмотрено и отвергнуто:

- **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть
  похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного
  адреса» становятся локально невоспроизводимыми.
- **Подставлять по пустому значению `Remote-User`** — пустое значение штатно
  шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы
  человеком того, кому провайдер отказал.

### 6. Имена заголовков: ключами конфига, но перечень порождается константами

Цена названа прямо: сегодня имена — константы транспорта, и у них один дом.
Секция, где имя заголовка служит именем ключа, даёт этим именам **второе
упоминание**, а второе упоминание расходится с первым молча.

**Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён
порождается теми же константами транспорта. Ключ, не совпавший ни с одной из
них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому
виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML
ключ чувствителен.

Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет
его содержимое, и совпадение сверяет машина, а не внимательность.

Рассмотрено и отвергнуто:

- **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не
  узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и
  четвёртое читать не умеет.
- **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое
  дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг
  перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо.
  Оставлено вариантом на случай, если человек решит платить иначе.

Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример
владельца записывается так:

```toml
[auth.test_headers]
Remote-User = "foobar"
Remote-Email = "foobar@example.com"
```

### 7. Что означает `[server] debug` и чего он не включает

Признак означает одно: **этот прогон идёт на машине разработчика, и сервису
позволено подставить то, что в бою даёт окружение.** Сегодня подставляется
ровно одна вещь — заголовки входа.

Чего признак **не** включает и включать не должен без отдельного решения
человека:

- уровень журнала (он зашит и настройкой не является);
- тексты внутренних отказов и следы стека в ответах;
- вывод тел запросов и ответов внешних сервисов;
- снятие или послабление ограничителя частоты;
- подмену распознавателя памятью — это подстановка в коде,
  `internal/adapter/recognizer/memory.go`, и настройкой она не делается;
- послабление любой проверки старта;
- открытие любого адреса неузнанному.

Правило для следующей задачи: новое поведение вешается на этот ключ только
решением владельца и получает **свою** строку в спеке `access` или в спеке своей
capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с
открытым.

### 8. Где живёт проверка

Проверка охватывает две секции разом — `[server] debug` против
`[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в
`AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на
корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом
с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет»,
названное в `docs/conventions/config.md`, он не пытается: это отдельная работа.

Рассмотрено и отвергнуто: передавать признак отладки внутрь
`AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного
булева значения.

### 9. Журнал

- **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат —
  владелец, и сообщение ровно того рода, который конвенция называет «может стать
  проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`,
  перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это
  ключ к чужому архиву, и спека `access` запрещает его печатать наравне с
  адресом почты.
- **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и
  частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы
  журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`,
  `transport`.
- **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира
  не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`,
  `capability`, `transport`; подставляемых значений в ней нет.

  Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это
  проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User`
  **до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no
  login header")`, и запрос уходит дальше. Предупреждение `Login header came
  from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а
  подстановка работает ровно при его отсутствии. На отказном пути подстановки
  предупреждения не бывает никогда.

  Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт
  на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу
  стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию
  имитации, включённый предохранитель — и ни одной строки о том, что подстановка
  не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в
  перечне обязан стоять `::1`.
- Строка `Account created from login header` работает как работала: первый
  запрос локального прогона заводит учётную запись и виден на `INFO`.

### 10. Подкоманда `cmd/devtools proxy` убирается целиком

**Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
входить локально не остаётся тоже.

Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок
с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше
не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок
сам.

Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она
сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в
`usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4
`tasks.md`.

Рассмотрено и отвергнуто владельцем:

- **Оставить как есть** — два способа войти локально, и документация обязана
  каждый раз говорить, какой из них чей.
- **Оставить, сузив назначение** до воспроизведения поломок контура — тот же
  второй способ, только не названный в инструкции локального запуска.

## Risks / Trade-offs

- **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя
  «выключено» и отказ старта при заполненной имитации без предохранителя
  (решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно
  этот случай — заполненную имитацию при выключенном предохранителе.
- **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг
  из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это
  принято решением владельца (решение 4): боевой сервис с включённым
  предохранителем и заполненной имитацией отдаст архив всякому, чей запрос
  пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём.
- **Имя заголовка в конфиге разошлось с константой транспорта** → перечень
  принимаемых имён порождается константами, неизвестный ключ роняет старт
  (решение 6).
- **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy`
  убрана (решение 10); остаются автотесты, которые ставят заголовок сами.
- **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и
  правило «новое следствие — своя строка в спеке».
- **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит
  учётную запись, а удалять учётные записи сервис не умеет. Локальная база
  ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая
  была бы поломкой много хуже лишней учётной записи.

## Migration Plan

Шага схемы изменение не требует, данных не трогает. Существующий конфиг
продолжает работать без правки: умолчание предохранителя — «выключено», секция
имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня.
Откат — удаление двух ключей из конфига.

## Open Questions

Развилок не осталось: все закрыты владельцем на чекпоинте.
