# Триаж ревью — `config-test-headers-login`, 2026-08-23

## Сводка

- **Режим прогона:** по графу. Стадия — ревью кода.
- **База диффа:** `origin/master` совпадает с `HEAD`; предмет ревью — рабочее
  дерево, включая неотслеживаемые файлы.
- **Размер:** 16 изменённых файлов + 4 новых (`test_headers.go`,
  `test_headers_test.go`, `substitute.go`, `substitute_test.go`), ~240 строк
  правок в коде; удалена подкоманда `proxy` пакета `cmd/devtools`.
- **Сложность:** новый слой транспорта, новая подсекция конфига с проверкой
  старта, снятая оснастка; трогает барьер входа.
- **Метка:** `large`. Обоснование разметки — своих тем проекта сверх ядра не
  заведено, поэтому `basics` не запускается: все темы разобраны именными
  проходами.
- **Состояние гейта:** ЗЕЛЁНЫЙ. `task gate BASE=origin/master`, 13 шагов,
  `go test -race` с работающим детектором, `golangci-lint` 0 issues,
  `govulncheck` без находок. Сообщено проходом `autotests`; триаж гейт
  не перезапускал.
- **Находок на входе:** 21 (A1; S1–S4; C1–C5; R1–R3; V1–V3 + три свойства
  `adversary` без пути; O1). **Осталось в первых двух секциях:** 7.

### План с исходом по каждой теме

| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1–S4) |
| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный |
| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1–C5) |
| `architecture` | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта** проходом `architecture`, 3 находки (R1–R3) + 2 замечания «дешевле до мерджа» |
| `security` | `docs/security.md`, «Периметр» | доказательство | `adversary` | **закрыта** проходом `adversary`, 3 находки (V1–V3) + 3 свойства без построенного пути; пути обхода **самой подстановки** не построено (6 отрицательных проб) |
| `operations` | `docs/architecture.md`, «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта** проходом `ops`, 1 находка (O1) + подтверждённый факт по постмортему 2 |
| тема проекта | своих тем нет | — | `basics` не запускался | **дома у темы нет**; `basics` на метке `large` не запускается по плану — это не пропуск |

**Тем без отчёта нет.** Все шесть заявленных домов дали отчёт.

### Сигнал о заниженной метке

**Пришёл от одного прохода, возражений нет.** `review-code` метку `large`
подтвердил и занижения не увидел. `review-basics` на этой метке не запускался,
поэтому второго независимого голоса нет — согласия двух проходов здесь не было
и быть не могло.

### Находка о самом прогоне

**Ни один из шести проходов не объявил свой потолок.** Устав велит называть
строкой, сколько находок показано, каков был потолок и что осталось за срезом;
такой строки нет ни у `specs`, ни у `code`, ни у `architecture`, ни у
`adversary`, ни у `ops`, ни у `autotests`. Это ровно тот повторяющийся пробел,
который записан в [docs/review.md](../../../../docs/review.md) строками 25–32 по
прогону `telegram-enabled-flag`: «находок больше нет» в отчёте прохода
неотличимо от «больше не поместилось». Механизации нет; сверка триажа —
единственное, что осталось, и она этот пробел только называет, а не закрывает.

### Дедупликация

- `A1` + `S2` — одна причина (строка журнала старта строится непроверенной
  функцией). Сведены; **понижены**: триаж добыл им однократный оракул прогоном
  бинарника, см. ниже.
- `S3` + `O1` — **две причины, а не одна**, вопреки подсказке задания: первая —
  отброшенные метаданные разбора (`MetaData.Undecoded()`); вторая — зашитый
  уровень журнала. Разведены в находки 1 и 2: чинятся порознь, и починка одной
  не закрывает другую.
- `S4` + второе звено `O1` — одна причина (зашитый `LevelInfo`). Сведены в
  находку 1.
- `R3` + замечание `specs` про два места — одна причина. Сведены в находку 6
  вместе с `C1`: обе легли на одну строку `server.go:44`.
- `R1` + `R2` — разные причины в разных домах, сведены в одну находку 7 двумя
  названными правками: обе — один абзац, обе про то, что нормативный документ
  описывает изъятие мягче, чем оно есть.
- **Согласие проходов приоритет поднимало, `Confidence` — нет.** Ни одна
  формулировка ниже не опирается на «подтверждено N проходами».

---

## Блокирует мердж

### 1. Каждый отладочный след нового входа пишется уровнем, который в настоящем прогоне выключен: разработчик получает `401` на всём приложении и пустой журнал

- Файл: `cmd/transcriber/main.go:39-41`; `internal/controller/http/substitute.go:97-99`;
  `internal/controller/http/identity.go:106-110`
- Severity: major
- Confidence: high
- Оракул: **свой прогон настоящего бинарника.**
  `go build -o $S/transcriber ./cmd/transcriber`, конфиг из `config.example.toml`
  с `debug = true`, `trusted_proxies = ["127.0.0.1", "::1"]` и правильной секцией
  `[auth.test_headers]`, запрос `curl http://127.0.0.1:18099/app/me` → `200`.
  В журнале — стартовая `WARN "Identity headers are substituted from configuration" headers=[Remote-User]`
  и `INFO "Account created from login header"`; **строки
  `"Identity headers substituted from configuration"` нет ни одной**.
  Второй прогон, тот же конфиг с опечаткой в имени секции → `код=401`, и в
  журнале ровно `INFO "Incoming request" ... http.status_code=401` без единой
  строки о причине.
  Подтверждающая команда:
  `grep -rn "\.Debug(" --include=*.go internal/ cmd/ | grep -v _test` → 6 мест;
  `grep -rn "LevelDebug\|Level:" --include=*.go cmd/ internal/ | grep -v _test`
  → единственная точка сборки логгера `cmd/transcriber/main.go:40`
  `Level: slog.LevelInfo`, ключа конфига у уровня нет, `os.Getenv`/`os.LookupEnv`
  в непроверочном коде нет вовсе.
- Последствие: **все шесть отладочных строк сервиса недостижимы в любом
  настоящем прогоне** — и обе, которые завела эта задача. Дельта-спека при этом
  требует отладочную строку прямо (`specs/access/spec.md:193`: «Запрос, которому
  заголовки подставлены, SHALL писаться на отладочном уровне») и в том же файле,
  строка 394, сама формулирует цену: «поломка, записанная уровнем, который в бою
  выключен, не записана вовсе». Критерий приёмки 9 — различимость «узнан
  подстановкой» от «узнан прокси» на уровне запроса — в действительности не
  достигнут ничем. Второе звено дороже первого: `identity.go:106` пишет «Request
  carries no login header» тоже отладочным уровнем, а путь «заголовка нет,
  предохранитель не включён» стал **единственным путём первого локального
  запуска у каждого** после удаления подкоманды `devtools proxy`. Человек видит
  `401` на всём приложении и пустой журнал — то самое «без единого следа», ради
  устранения которого заведены отказы старта.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S4), `ops` (O1, второе звено); оракул добыт триажем
  заново.
- **Действие: развилка.**

  > Отладочные строки сервиса не печатаются никогда: уровень журнала зашит
  > `LevelInfo` в единственной точке сборки логгера, ключа конфига у него нет.
  > Спека этой задачи требует отладочную строку подстановки, и критерий 9 на ней
  > стоит. Что делаем?
  >
  > 1. **Завести ключ уровня журнала** (`[server] log_level` либо уровень,
  >    выводимый из `[server] debug`). Спека прямо запрещает предохранителю
  >    трогать уровень журнала, значит это отдельный ключ и правка спеки
  >    `access` — расширение scope и новое имя ключа конфига, а имя ключа
  >    `CLAUDE.md` относит к необратимому и требует спросить человека.
  > 2. **Поднять уровень двух строк до `Info`** — «подстановка сработала» и
  >    «заголовка входа нет». Обе штатны и частотны; в бою первая не печатается
  >    вовсе (предохранитель выключен), вторая станет строкой на каждый
  >    неузнанный запрос. Правка спеки нужна тоже — она уровень назначает
  >    поимённо.
  > 3. **Снять требование отладочной строки из спеки и критерий 9 вместе с ним**,
  >    записав строкой цену: наблюдаемости подстановки на уровне запроса у
  >    сервиса нет, стартовой `WARN`-строки признано достаточно.

### 2. Опечатка в имени самой секции `[auth.test_headers]` не судится ничем: сервис поднимается, никого не узнаёт и следа не оставляет — ровно тот исход, ради которого заведён второй отказ старта

- Файл: `internal/config/config.go:242-256` (метаданные разбора отбрасываются);
  `internal/config/test_headers.go:33-43`
- Severity: major
- Confidence: high
- Оракул: **свой прогон, два уровня.**
  (1) Модульный: декодер `BurntSushi/toml v1.5.0` на входе
  `[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` даёт
  `decode err=<nil>`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`,
  `Server.Debug=true len(TestHeaders)=0 substituting=false`,
  `ValidateTestHeaders err = <nil>`. Контроль: та же опечатка **внутри** секции
  (`Remote-Usr`) ловится —
  `auth: секция [auth.test_headers] называет заголовок, которого сервис не читает: Remote-Usr`.
  (2) Сквозной: настоящий бинарник на конфиге с `[auth.test_headrs]` поднимается
  без единого предупреждения, `curl /app/me` → `401`, в журнале только
  `INFO "Incoming request" ... http.status_code=401`.
- Последствие: `design.md` заявляет защиту словами «опечатка в имени иначе
  кончается сервисом, который никого не узнаёт, без единого следа», и защита эта
  покрывает опечатку в имени **ключа**, но не в имени **секции**. Разница для
  человека нулевая — он ошибается в обеих строках одинаково, — а исход
  противоположный: в одном случае отказ старта с именем ключа, в другом
  безмолвный `401` на всём приложении. Стоимость несёт каждый первый локальный
  запуск, потому что подкоманды `devtools proxy` больше нет и обходного пути не
  осталось. Отдельно: та же дыра проглатывает любую опечатку в **любой** секции
  конфига, не только в новой.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S3), `ops` (O1, первое звено); оракул добыт триажем
  заново.
- **Действие: развилка.**

  > `toml.DecodeFile` возвращает `MetaData`, и в нём лежит перечень ключей,
  > которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени
  > секции проходит молча. Что делаем?
  >
  > 1. **Узкая правка в границах задачи:** при `[server] debug = true` и пустой
  >    секции имитации ронять старт (или писать `WARN`), если
  >    `MetaData.Undecoded()` называет что-нибудь под `auth`. Ловит ровно этот
  >    класс, не трогает остальной конфиг, требует одного нового абзаца в
  >    дельта-спеке.
  > 2. **Правило шире задачи:** судить `MetaData.Undecoded()` на всём конфиге —
  >    непонятый ключ роняет старт с его именем. Закрывает опечатки во всех
  >    секциях разом, но это новая норма в `docs/conventions/config.md` и
  >    отдельный change: сегодняшние конфиги на сервере могут нести ключи,
  >    которых структура уже не знает (постмортем 2 подтвердил, что откат
  >    бинарника поверх нового конфига сегодня безопасен **именно** потому, что
  >    лишние ключи молча игнорируются, — правило это свойство снимет).
  > 3. **Ничего не делать в коде**, а записать исход строкой в спеке и в
  >    `config.example.toml`: «опечатка в имени секции даёт `401` без следа;
  >    сверяйте имя секции по образцу». Дешевле всего и честнее молчания.

### 3. Спека объявляет матрицу отказов старта полной и называет три случая, а код даёт четвёртый: следующий change снимет его как самодеятельность, и одно из двух значений секции начнёт теряться молча

- Файл: `internal/config/test_headers.go:86-93`;
  `openspec/changes/config-test-headers-login/specs/access/spec.md:109`;
  `openspec/changes/config-test-headers-login/tasks.md:227-231`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Вход
  `[server] debug = true` + `[auth.test_headers]` с `"Remote-User" = "one"` и
  `"remote-user" = "two"`: декодер даёт `len(c.Auth.TestHeaders) == 2`,
  `HeaderSubstitution()` возвращает `map[Remote-User:two]` — **значение `"one"`
  потеряно**, — а `ValidateTestHeaders` роняет старт строкой
  `auth: в секции [auth.test_headers] два ключа называют один заголовок: имя
  заголовка нечувствительно к регистру, и одно из значений потерялось бы молча`.
  Дословный текст спеки на строке 109: «Отказом MUST быть **каждый из трёх**
  случаев»; сценария про совпадение канонических имён в дельте нет. Дословный
  текст `tasks.md:227`: «Матрица отказов старта **полна и тотальна**: у каждой
  комбинации новых ключей объявлен исход».
- Последствие: реализатор, читающий спеку как полную матрицу — а `tasks.md`
  велит читать её именно так, — снимет четвёртую проверку как не подпёртую
  требованием. После снятия конфиг с двумя ключами разного регистра поднимает
  сервис, схлопывает их в один и молча берёт тот, который последним лёг в карту:
  порядок перебора TOML не гарантирован, и человек получает то одно значение, то
  другое от запуска к запуску. Это в точности класс «молчаливая потеря
  значения», ради которого проверка и написана. Правка целиком в границах
  задачи: дельта-спека — часть этого change.
- Предложение: дописать четвёртый случай в требование «Настройка, открывающая
  вход всем, роняет старт» и завести к нему сценарий; поправить `tasks.md:227`
  так, чтобы перечисление читалось «предохранитель × (пустая / заполненная /
  заполненная негодно / заполненная неоднозначно)».
- Найдено проходом: `specs` (S1); оракул добыт триажем заново.
- **Действие: инлайн.**

---

## Стоит исправить сейчас

### 4. Бинарник читает `.env`, проект называет его секретосодержащим, а `.gitignore` его не игнорирует: первый же ключ, положенный туда, уезжает в git необратимо

- Файл: `.gitignore` (нет записи); `.dockerignore:21`;
  `cmd/transcriber/main.go:114-117`
- Severity: major
- Confidence: high
- Оракул: **свои команды.** `git check-ignore -v .env` → пусто, `exit=1` (файл не
  игнорируется). `.dockerignore:19-21` — комментарий «Настройки с секретами.
  Образ берёт конфиг на сервере, а не из дерева.» и следом строка `.env`.
  `cmd/transcriber/main.go:115` — `godotenv.Load()`. Прогон настоящего бинарника
  печатает при каждом старте
  `level=WARN msg="Warning: .env file not found, using system environment variables"`.
  При этом `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ | grep -v _test`
  → **ни одного совпадения**: переменных окружения сервис не читает нигде.
- Последствие: инвариант `CLAUDE.md` — «**Секрет не покидает конфиг.** Ключ
  SpeechKit и пара ключей Object Storage не попадают в git… Нарушение
  необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки.
  **critical**». Путь к нарушению короткий и сервис сам его подсказывает:
  стартовая `WARN`-строка сообщает владельцу, что `.env` ожидается, тот заводит
  файл с ключами — и `git add .` кладёт его в индекс, потому что
  `.gitignore` про `.env` не знает. Единственный барьер — `gitleaks git --staged`
  на pre-commit, а он судит по образцам и на произвольном ключе SpeechKit молчать
  вправе. Сама подсказка при этом ложная: ключи из `.env` не сработали бы, читать
  переменные окружения сервису нечем. **`critical` не ставлю:** пути до
  настоящего секрета в git я не построил — работающего мотива положить ключ в
  `.env` у владельца нет, — значит по контракту выше `major` эта находка не
  поднимается.
- Предложение: строку `.env` в `.gitignore` рядом с `config.toml` — это одна
  строка и весь инлайн. Мёртвый `godotenv.Load()` вместе с зависимостью и
  вводящей в заблуждение `WARN`-строкой уходит задачей: правка не этого change.
- Найдено проходом: `adversary` (свойство без построенного пути); оракул и
  переформулировка — триаж (проход утверждал, что `.env` читается, но не заметил,
  что читать из него некому).
- **Действие: инлайн** — только строка в `.gitignore`. Снятие `godotenv` —
  задачей, см. «Урожай».

### 5. Чужой адрес почты, занятый первым пришедшим, навсегда и молча оставляет без почты его настоящего владельца — и `docs/security.md` утверждает обратное

- Файл: `internal/controller/http/identity.go:134-138`;
  `internal/adapter/repo/sqlite/identity.go:77-110`; текст
  `docs/security.md:71-76`
- Severity: major
- Confidence: high
- Оракул: **свой падающий сценарий, прогнан на настоящей поверхности сервиса**
  (`setupEnv` + `AppChain`, база SQLite во временном каталоге, маршрут
  `/app/me`). Два шага:
  1. Запрос с `Remote-User: squatter` и `Remote-Email: victim@corp.example` с
     доверенного адреса → `200`. `SELECT email FROM users WHERE provider_login='squatter'`
     → `victim@corp.example`.
  2. Запрос настоящего владельца: `Remote-User: victim`,
     `Remote-Email: victim@corp.example` → `200`, `Account created from login header`.
     `SELECT email ... WHERE provider_login='victim'` → **`""`**. Строк журнала о
     занятом адресе — ноль.

  Второй прогон показывает асимметрию правила прямо: два значения `Remote-User`
  → `401`; два значения `Remote-Email` → `200`, и в базу ложится **первое**
  (`attacker-first@example.com`).
- Последствие: `docs/security.md:71-74` называет прокси, **добавляющий** заголовок
  вместо замены, главной угрозой периметра и пишет: «Половину этой беды сервис
  закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого».
  Утверждение верно ровно для одного заголовка из трёх. `Remote-Name` и
  `Remote-Email` берутся `Get()` — первым значением, то есть присланным анонимом,
  — и логин от них не защищает. Уникальный индекс `idx_users_email` не даёт
  двум записям делить адрес, `EnsureUser` найденную запись не переписывает
  никогда, а ветвь отката заводит настоящего владельца через
  `insertUser(tx, login, name, "")` **без строки журнала**. Порча тихая и
  постоянная: обратного пути у неё в коде нет. **Сегодня цена почти нулевая** —
  `contract.UserAccount` поля `Email` не имеет вовсе, приложение почту не
  показывает, доступ по ней не выдаётся; поэтому не блокирует мердж. Цена
  появляется в тот день, когда почту начнут читать (в бэклоге лежит
  `api-tokens`), и к этому дню колонка уже будет испорчена молча.
- Предложение: судить `Remote-Name` и `Remote-Email` тем же правилом, что
  `Remote-User` — `len(r.Header.Values(name)) != 1` даёт пустое значение, а не
  первое; ветвь отката уникальности почты обязана оставить строку журнала
  видимого уровня.
- Найдено проходом: `adversary` (V1); оракул добыт триажем заново, обоими
  звеньями.
- **Действие: развилка.**

  > Правка меняет боевой путь узнавания, а не отладочную подстановку, — то есть
  > выходит за scope этой задачи и требует нового требования в спеке `access`
  > (сегодня спека нормирует многозначность только для `Remote-User`). Что
  > делаем?
  >
  > 1. **Взять в этот change:** три строки в `identity.go` + строка журнала в
  >    ветви отката + абзац в требование спеки. Логика ровно та же, что слой
  >    подстановки уже применил к себе («Запрос без `Remote-User`, но с
  >    `Remote-Email` иначе собрал бы личность из двух источников»), — правило
  >    просто не донесено до соседних двадцати строк.
  > 2. **Отдельной задачей**, потому что предмет другой и цена сегодня нулевая:
  >    почту никто не читает. Тогда `docs/security.md:71-74` надо поправить в
  >    этом мердже — фраза «половину этой беды сервис закрывает сам» описывает
  >    периметр точнее, чем он есть, и ссылаться на неё нельзя.
  > 3. **Только документ:** записать асимметрию как принятую цену, потребовав от
  >    прокси перезаписи всех трёх `Remote-*` (требование к контуру уже записано,
  >    см. «Недоступно проверке»), и колонку почты не трогать.

### 6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам

- Файл: `internal/controller/http/server.go:27-49` (строка 44
  `if len(substitution) > 0`); `internal/config/test_headers.go:33-43`;
  `internal/controller/http/substitute_test.go:229-246`
- Severity: minor
- Confidence: high
- Оракул: чтение с двумя дословными сопоставлениями.
  (1) Комментарий `server.go:30-31` утверждает: «Судит эту пустоту точка входа:
  перечень приходит сюда уже готовым», — а строка 44 судит её второй раз своим
  выражением. Тождество двух формулировок (`c.Server.Debug && len(TestHeaders) > 0`
  против `len(substitution) > 0`) сегодня держится только тем, что
  `HeaderSubstitution` возвращает `nil` в ложной ветке; ничем другим оно не
  закреплено.
  (2) Единственная проверка на пустую имитацию — `TestEmptySectionLeavesHeadersUntouched`
  — судит три вещи: код `401`, неизменное число учётных записей и отсутствие в
  журнале строки `"Identity headers are not substituted"`. Строка эта пишется
  **только** в ветке недоверенного пира (`substitute.go:80`), а запрос проверки
  идёт с `trustedPeer`, — значит утверждение истинно при любом исходе. Первые два
  утверждения тоже истинны при снятом страже: заголовка `Remote-User` в запросе
  нет, слой удалил бы `Remote-Email` и пропустил дальше, узнавание всё равно
  ответило бы `401` и записи бы не завело.
  Мутационную сверку — снять страж и убедиться, что проверка зеленеет, — не
  делал: `CLAUDE.md`, «Запреты», прямо запрещает мутационную сверку оракулов.
- Последствие: сценарий дельта-спеки «Пустая секция имитации ничего не трогает»
  не проверен ничем, хотя в наборе проверок выглядит проверенным, — а именно к
  этому сценарию спека привязывает своё «поведение, которого в бою нет»
  (срезанный `Remote-Email` у запроса без `Remote-User`). Дубль предиката при
  этом расходится в первой же правке, дающей непустой признак при пустой карте:
  исход будет «старт предупреждает о подстановке, а слоя в цепочке нет» — и
  предупреждение соврёт.
- Предложение: `AppChain` принимает вторым значением сам предикат
  (`substituting bool`) вместо того, чтобы выводить его из длины карты; проверка
  судит **дошедшее до узнавания** — что `Remote-Email`, посланный запросом,
  доехал до обработчика нетронутым, — а не журнал.
- Найдено проходами: `code/техника` (C1), `architecture` (R3), `specs`
  (замечание про два места).
- **Действие: инлайн.**

### 7. Два нормативных документа описывают изъятие мягче, чем оно есть: паспорт обосновывает его доводом, который в изъятии не действует, а принцип архитектуры запрещает ровно то, что сам приводит примером

- Файл: `docs/passport.md:65-75`; `docs/architecture.md:84-91`
- Severity: minor
- Confidence: high
- Оракул: дословное сопоставление внутри одного изменения.
  (1) `docs/passport.md:73` — «подставленное имя проходит то же узнавание, что и
  пришедшее, **а кого пускать, по-прежнему решает провайдер**». Дельта-спека в
  том же изменении: «Подстановка — это настройка, которой сервис называет
  пришедшего сам, **никого не спросив**» (`substitute.go:32-33` теми же словами).
  Провайдера в изъятии нет вовсе. Там же паспорт пишет «*Изъятие одно, и оно про
  прогон без контура*» — код таким не ограничен: подстановка работает везде, где
  адрес пира доверенный, и `docs/security.md:97-102` это признаёт прямо
  («Боевая поломка машиной не исключена»). Два документа одного изменения
  расходятся об одном изъятии.
  (2) `docs/architecture.md:84-91` — заголовок нормы «Подставной собеседник
  живёт **в коде или в оснастке**», тело — «только под ключом, названным своим
  предметом, — **не под общим словом вроде «режим отладки»**», и следом
  собственным примером норма называет случай, который живёт в боевом бинарнике и
  стоит под `[server] debug`. Единственная операционная формулировка изъятия —
  закрытый перечень следствий — живёт в спеке, и принцип на неё не ссылается.
- Последствие: `docs/passport.md` — единственный дом границы домена, и `CLAUDE.md`
  велит читать его перед задачей. Записанная так граница перестаёт исключать то,
  ради чего заведена: следующая задача (в бэклоге лежит `api-tokens`) сможет
  сослаться на строку «кого пускать, решает провайдер» как на действующее
  ограничение, которого нет. Норма архитектуры, обратно, читается как «шипящая
  реализация нарушает наш собственный принцип» — а ключ `[server] debug`
  владелец подтвердил дважды и переоткрывать его нельзя; значит расходится норма,
  а не код. `docs/review.md` при этом уже объявил пробел закрытым этой нормой,
  то есть на неё уже сослались.
- Предложение: в паспорте заменить довод на верный («сервис называет пришедшего
  сам; границы домена это не двигает, потому что учётных записей он по-прежнему
  не заводит и допуска не проверяет — заведение строки первым обращением
  остаётся зеркалированием») и снять слова «оно про прогон без контура». В
  `docs/architecture.md` свести принцип к связывающему правилу со ссылкой на
  требование спеки (закрытый перечень следствий предохранителя), не повторяя
  перечень второй копией.
- Найдено проходом: `architecture` (R1, R2).
- **Действие: инлайн** (две правки, оба файла — документация).

---

## Гипотезы без доказательства

Понижены: оракула на этом прогоне триаж не добыл — по бюджету (одна попытка на
находку ушла на `critical`/`major`) либо по запрету проекта.

### Имя копии в каталоге данных выводится из идентификатора записи одним инкрементом

- Файл: `internal/ident/ident.go:79-99`; `internal/service/transcribe.go:176-201`
- Было: minor / high (`adversary`, V2). **Осталось minor:** оракул у прохода
  есть (`TestProbeStorageNameDerivesFromRecordID`, `TestProbeDerivationRate` —
  49 из 50 приёмов подряд), но триаж его не воспроизводил, а согласие одного
  прохода с самим собой подтверждением не является.
- Последствие: инвариант `CLAUDE.md` «Имя файла на диске задаёт сервис, а в
  журнал не идёт… строка журнала иначе стала бы бессрочным ключом к чужой
  записи» соблюдён по букве и не по назначению: `record_id` и `file_ext`
  присутствуют в журнале порознь и дают имя арифметикой. Доступа это не даёт —
  карточка и файл сужены владельцем, — поэтому не выше minor.
- Предложение: случайное положительное приращение вместо единицы либо снять
  посылку из инварианта. Уходит задачей.

### Управляющий знак в расширении имени даёт отправителю `500`, а владельцу — `ERROR`, неотличимый от аварии хранилища

- Файл: `internal/service/transcribe.go:171-188`;
  `internal/adapter/repo/sqlite/file_repo.go:56-67`
- Было: minor / high (`adversary`, V3). **Осталось minor:** падающий тест у
  прохода есть, триаж его не воспроизводил.
- Последствие: комментарий `transcribe.go:171-175` объявляет этот исход
  недопустимым и ради него заведён `maxExtLen`; разрез сделан по длине и не
  сделан по составу знаков. Побочно `newWorkFile` не снимает путь с
  `*os.PathError`, и путь во временном каталоге уезжает в строку `ERROR`.
- Предложение: судить состав знаков расширения тем же приёмом, что и длину.
  Уходит задачей.

### Текст отказа провайдера уезжает в `error_text` и в журнал целиком, а самый ожидаемый его вид несёт `sourceURI` — бакет и ключ объекта

- Файл: не локализован проходом точнее уровня «ветвь отказа распознавания»
- Было: minor / medium, **оракула нет и быть не может на этом прогоне**:
  настоящий SpeechKit нужен, а `CLAUDE.md`, «Запреты», прогон на реальных ключах
  запрещает («Yandex Cloud за деньги»). Строка живёт в
  `docs/review.md`, «Недоступно проверке», первым списком.
- Последствие (условное): ключ объекта в `error_text` виден владельцу записи
  карточкой; инвариант «Содержимое записи остаётся приватным» им прямо не
  нарушен, но перечень того, что уходит в текст ошибки, ничем не ограничен.
- Предложение: задачей — ограничить текст внешнего отказа перечнем полей, а не
  пересказывать его дословно.

### Требования «прокси обязан дописывать `X-Forwarded-For`» нигде не записано, в отличие от `Remote-*`

- Файл: `docs/security.md:103-113`
- Было: minor / medium (`adversary`). **Оракула нет:** правило живёт в
  `files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и
  `docs/review.md`, «Недоступно проверке», относит поведение обратного прокси к
  тому, чего не проверит ни один проход.
- Последствие (условное): `docs/security.md` описывает чтение цепочки
  справа налево словами «правое приписал ближайший к нам прокси» — это
  предположение о прокси, а не требование к нему. Прокси, который `X-Forwarded-For`
  не дописывает вовсе, оставляет ключ бюджета ограничителя целиком в руках
  спрашивающего.
- Предложение: задачей — дописать требование к контуру рядом с уже записанным
  требованием про `Remote-*`.

---

## Promote candidates

- **Правило «`internal/config` не знает транспорта» механизируемо, но не
  механизировано.** Норма записана этим же изменением
  (`docs/conventions/config.md`, «Проверка, охватывающая две секции разом»), а
  `grep -n "config" internal/archrules/arch_test.go` не даёт ни одного
  совпадения: сканера у правила нет. Кандидат — правило `internal/archrules` по
  нетестовым файлам пакета. Предложил проход `architecture`.
  **Оговорка к механизации:** `internal/config/test_headers_test.go:1-8`
  объявлен `package config` и импортирует `internal/controller/http` — то самое
  ребро. В рабочем бинарнике его нет, но `go test ./internal/config` линкует всю
  поверхность HTTP, а будущая надобность конфига в транспорте даст цикл импорта
  на уровне проверок. Правило по нетестовым файлам этот файл пропустит; правило
  по всем — уронит гейт до переноса файла во внешний `package config_test`.
- **`errors.Join` в проверках конфига.** `docs/conventions/errors.md`,
  «Несколько ошибок», называет проверку конфига поимённо и требует «все проблемы
  разом»; `ValidateTestHeaders` возвращается на первом несовпадении, записи
  «*Расхождение:*» у пункта нет. Это претензия на правило, а не на этот код:
  проверки остальных секций ведут себя так же. Предложил проход `code` (C5).
- **Сверка `toml.MetaData.Undecoded()` как норма конфига.** Вариант 2 развилки
  находки 2. Если владелец выберет узкую правку, кандидат остаётся здесь:
  непонятый ключ в любой секции сегодня проходит молча.

---

## Границы покрытия

### План: темы, глубины, дома

Воспроизведён таблицей в сводке выше. Дома: `openspec/specs/access/spec.md` +
дельта (`requirements`, разбор); `CLAUDE.md`, «Гейт» (`autotests`);
`docs/conventions/` целиком (`conventions`, разбор); `docs/architecture.md` +
источник `docs/passport.md` (`architecture`, доказательство); `docs/security.md`,
«Периметр» (`security`, доказательство); `docs/architecture.md`, «Эксплуатация» +
источник `docs/database.md` (`operations`, доказательство). **Тема без дома
одна** — «тема проекта»: своих тем сверх ядра проект не завёл, и это состояние
плана, а не пропуск.

### Какие проходы запускались

Метка `large`, режим «по графу». Запускались шесть: `autotests`, `specs`, `code`,
`architecture`, `adversary`, `ops`. Триаж — седьмой.

### Какие не запускались и почему

- `basics` — **по метке**: на `large` он не запускается, потому что все темы
  ядра разобраны именными проходами, а своих тем у проекта нет. Прямое следствие:
  второго независимого голоса о заниженности метки нет — сигнал пришёл только от
  `review-code`.
- Проход независимой реализации — снят из конвейера по стоимости.
- Проход про идиоматичность — упразднён.

### Чего каждый запущенный проход не мог проверить в принципе

- `autotests` — судит гейт и покрытие, но не судит, **что** проверяют зелёные
  проверки: находка 6 (проверка, которая не может упасть) из его charter'а не
  видна, её принёс `code`.
- `specs` — судит соответствие кода дельте и полноту дельты; не судит, работает
  ли требование в настоящем прогоне (потому S4 у него остался рассуждением, а
  оракул добыл триаж).
- `code` — судит записанные конвенции и технику; периметра и эксплуатации не
  касается.
- `architecture` — судит направления зависимостей и нормы домов; карту графа
  собирал **вручную**, подготовленной команды у проекта нет.
- `adversary` — строит пути; поведения настоящих внешних собеседников не
  проверяет вовсе.
- `ops` — судит эксплуатацию по замерам своего прогона; профиля настоящей
  нагрузки у него нет (проект работает на единицах записей в день).
- **Триаж (этот проход)** — ничего нового не находит по определению: работает с
  чужими выводами, кода в поисках дефектов не читает. Пропуск любого прохода —
  пропуск триажа тоже, и единственное средство против него — поимённая сверка
  плана выше.

### Что осталось целиком на человеке

**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке», первый
список — воспроизводится отдельно от второго намеренно):

- `operations`: поведение внешних сервисов под нагрузкой и на границах —
  SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
  условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
  сервиса — **от прокси зависит весь барьер**, он обязан заголовки `Remote-*`
  перезаписывать, а не пропускать пришедшие; правило живёт в
  `pet-project-server`. **Прямо относится к находке 5:** сценарий
  дописывающего прокси проверить отсюда нечем, а асимметричную реакцию сервиса
  на него — можно, и она проверена;
- `security`: поведение браузера с куками (класс пуст с 2026-08-22, строка стоит
  как маркер).

**Перестали проверять сознательно** (второй список, тот же раздел):

- `autotests`: разбор вывода настоящего `ffprobe` — проверки получают
  длительность от подставного источника; решение и цена в
  `adr/ADR-2026-08-11-stub-adapters-in-tests.md`;
- работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно и
  этот прогон его поднимал; **остаток** — за настоящие SpeechKit и Object
  Storage живой прогон не отвечает, ключи выдуманные, распознавание подменяется
  в коде. Вход живой прогон с 2026-08-22 проверяет целиком, и раздел уже
  переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.

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

### Четыре строки, которых не принесёт ни один проход

1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
   его не открывает. Расхождение изменения с записанным решением ловит скилл
   `av-dev:doc-healthcheck`, а не ревью. В этом изменении есть чему разойтись:
   `ADR-2026-08-22-login-by-trusted-header.md` описывает вход, к которому
   заведено изъятие, и сверен с ним никем не был.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
   процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмарк
   `BenchmarkSubstituteIdentityHeaders` (2972 ns/op, 5718 B/op, 17 allocs/op) —
   замер прохода `ops`, покрытие (`test_headers.go` 100 %,
   `SubstituteIdentityHeaders` 94,4 %, `SubstitutedHeaderNames` 0,0 %) — вывод
   `go tool cover -func` прохода `autotests`, остальные числа — прогоны триажа с
   приложенными командами.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
   проходом.** Различение «идиоматично против распространено» не спрашивает
   никто с тех пор, как упразднён проход про идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
   нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
   знаю, чего не знаю» на этом прогоне не доставал никто.

Метка — `large`, поэтому пятой строки (сужение `security`/`operations`/
`architecture` до записанных инвариантов) здесь нет: все три темы разобраны
именными проходами на глубине «доказательство».

### Каких документов проекта не хватило

- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст**, прочитан
  целиком (5 записей, две из них отменённые намеренно). Ни одна находка этого
  прогона под них не подпадает; отсев шёл и по проектному входу, и по общим
  критериям.
- **Раздела инвариантов `CLAUDE.md` хватило.** На него опираются находки 4
  («Секрет не покидает конфиг», **critical**, необратимо) и гипотеза про имя
  копии («Имя файла на диске задаёт сервис, а в журнал не идёт», **critical**).
  Ранжирование по обратимости взято оттуда же, а не выведено из кода.
- **Не хватило: у `[server] debug` нет дома, называющего исчерпывающий перечень
  его следствий в одном месте.** Перечень объявлен трижды — комментарием
  `internal/config/config.go:65-71`, комментарием `config.example.toml:7-13` и
  требованием дельта-спеки, — и три копии уже расходятся: комментарий конфига
  называет пять исключённых следствий, образец — четыре (в нём нет «проверок
  старта»). Строка о том, где перечень канонический, отсутствует, и триаж
  выбирал спеку по общему правилу «спека нормативна», а не по записанному
  указателю.
- **Не хватило: `docs/conventions/logging.md` не называет, каким уровнем
  пользоваться, когда отладочный недостижим.** Уровень журнала у сервиса зашит,
  ключа у него нет, и конвенция про это молчит — поэтому находка 1 подана
  развилкой, а не инлайном: своего основания выбрать вариант у триажа нет.
- **Оракул из журнала дефектов не применялся:** ни одна запись журнала
  `docs/review.md` не описывает класс, совпадающий с находками этого прогона.
  Ближайшая — «ключ бюджета ограничителя выбирал тот, кого ограничивают»
  (2026-08-23) — соседствует с гипотезой про `X-Forwarded-For`, но предмет у неё
  другой.

### Сработавшие потолки

- **По проходам: ни один не сообщил свой потолок.** Ни `autotests` (1 находка),
  ни `specs` (4), ни `code` (5), ни `architecture` (3 + 2 замечания), ни
  `adversary` (3 + 3 свойства), ни `ops` (1) не сказали, каков был их предел и
  что осталось за срезом. Это находка о прогоне, вынесенная в сводку отдельной
  строкой; закрыть её отсюда нечем.
- **Потолок триажа сработал.** На входе 21 находка, в первые две секции влезло
  7. **Не влезло и здесь названо поимённо:**
  - `A1`/`S2` — `SubstitutedHeaderNames` не вызвана ни одним тестом
    (`go tool cover -func` → 0,0 %). Триаж добыл ей однократный оракул: прогон
    настоящего бинарника печатает `headers=[Remote-User]` — имена, не значения,
    — то есть **дефекта сегодня нет**, есть отсутствие защиты от регрессии
    против MUST NOT дельта-спеки («строка старта MUST не нести значений»).
    Понижено до minor, инлайн-правка на один тест, если у оркестратора останется
    бюджет;
  - `C3` — умолчания `Debug` и `TestHeaders` заданы нулём типа, а не в
    `defaultConfig()`. Нарушена записанная конвенция
    `docs/conventions/config.md:181-191`: «Умолчания задаются в `defaultConfig()`…
    Новое поле требует правки обоих мест», и там же форма обязательного поля —
    «умолчания нет ни в `defaultConfig()`, ни по нулевому значению типа». Форма
    записи необязательного поля стала неотличима от формы обязательного. `nit`,
    инлайн;
  - `C4` — `internal/config/test_headers_test.go` объявлен `package config` и
    импортирует `internal/controller/http`, ребро запрещено той же правкой в
    `docs/conventions/config.md`. `nit`; отражён оговоркой в Promote candidates,
    потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
  - `C5` — `ValidateTestHeaders` возвращается на первом несовпадении вместо
    `errors.Join`. Уехал в Promote candidates: правило шире этого кода;
  - `C2` — комментарий `Dockerfile:49-50` объясняет исключение `cmd/devtools`
    словами «оснастка разработчика (подставной прокси)», а подкоманды больше нет;
    приёмочный шаг `tasks.md:88` искал следы по строке `devtools proxy` и эту не
    поймал. Плюс `internal/controller/http/mounts.go:54-56` перечисляет три звена
    цепочки, тогда как `AppChain` одевает четыре. `nit`, инлайн, две строки;
  - **расхождение записи имени ключа**: `[auth] test_headers` в дельта-спеке,
    `docs/security.md`, `docs/architecture.md` и `docs/review.md` против
    `[auth.test_headers]` в `config.example.toml`, `README.md`, `CLAUDE.md`,
    `docs/conventions/config.md` и в текстах отказов старта самого кода
    (`test_headers.go:16`). Человек, ищущий в конфиге то, что назвал отказ
    старта, найдёт вторую форму; первая в файле не встречается вовсе. **Не
    находка этого прохода:** `CLAUDE.md`, «Гейт», относит согласованность
    документов между собой и с кодом к скиллу `av-dev:doc-healthcheck`. Названо
    здесь, чтобы не потерялось;
  - **рецепт локального входа записан четырьмя копиями** — `config.example.toml`
    (канон), `README.md:48-61`, `CLAUDE.md:276-288`,
    `docs/conventions/config.md:66-75`. Замечание `architecture` «дешевле
    переделать до мерджа»; по той же причине уходит в `doc-healthcheck`;
  - границы прохода `specs`, перенесённые без изменений: значения
    `Remote-Name`/`Remote-Email` в секции имитации на старте не судятся ничем
    (годность проверяется только у логина); `strings.TrimSpace` над именем ключа
    — поведение сверх спеки, последствий у него не найдено.

### Урожай враждебного прохода

Пути обхода **самой подстановки** — предмета этого изменения — не построено:
шесть отрицательных проб (два значения `Remote-User` при включённой подстановке
→ `401`; чужие `remote-email`/`remote-name` в нижнем регистре на живом сервере →
слой владеет тройкой целиком; сверка адреса идёт той же функцией, что у
узнавания; неразобранный `RemoteAddr` → отказ подстановки; канонизация ключей
против регистра, NBSP, ZWSP → старт падает; у `[server] debug` второго
потребителя нет). Состояния «подстановка работает, а владелец не знает» не
найдено. **Это положительный исход по теме, а не отсутствие работы.**

Построенное легло на соседнюю поверхность. Судьба каждой:

| Находка | Чинится этим мерджем? | Куда |
|---|---|---|
| V1 — почта берётся первым значением, сквоттинг молчит | **развилка**, вариант 1 берёт в мердж | находка 5 |
| `.env` не игнорируется git | **да**, одной строкой | находка 4 (инлайн) |
| `godotenv.Load()` мёртв (ничто не читает переменные окружения), а `WARN` на каждом старте приглашает завести `.env` | нет | **задачей**: снять зависимость `github.com/joho/godotenv` из `go.mod`, четыре строки из `cmd/transcriber/main.go:114-117` и строку `.env` из `.dockerignore` — либо, если переменные окружения нужны, завести им читателя. Оракул для задачи: `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ \| grep -v _test` → пусто; прогон бинарника печатает `level=WARN msg="Warning: .env file not found, using system environment variables"` |
| V2 — имя копии выводится из `record_id` инкрементом | нет | **задачей**, гипотеза выше; оракулы прохода названы поимённо |
| V3 — управляющий знак в расширении → `500` и `ERROR` | нет | **задачей**, гипотеза выше |
| Текст отказа провайдера с `sourceURI` в `error_text` | нет | **задачей**, оракула нет и на этом прогоне быть не может (запрет на реальный SpeechKit) |
| Требование к прокси про `X-Forwarded-For` не записано | нет | **задачей** в `docs/security.md`, рядом с уже записанным требованием про `Remote-*` |

Заведение задач — не работа триажа; формулировки выше отданы с оракулами, чтобы
ни одна не потерялась при переносе.

### Оракулы, добытые триажем

Все тесты писались во временные файлы пакетов и **удалены** после прогона;
рабочее дерево на момент сдачи отчёта содержит ровно те изменения, что были на
входе (`git status --short` сверен). Прогоны бинарника шли в каталог скретчпада,
не в `data/`; поднятый сервис остановлен за собой (`pkill`), порт освобождён —
правило `docs/review.md:16-23`.

1. `go test ./internal/config/ -run TestTriage -v` — три случая разбора TOML
   (дубль по регистру, опечатка в имени секции, опечатка в имени ключа).
2. `go test ./internal/controller/http/ -run TestTriage -v` — сквозной сквоттинг
   почты на настоящей поверхности `AppChain` и асимметрия правила многозначности.
3. `go build -o <скретчпад>/transcriber ./cmd/transcriber` + два прогона
   настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг
   с опечаткой в имени секции и конфиг правильный.
4. `git check-ignore -v .env`; `grep -rn "\.Debug(" --include=*.go internal/ cmd/`;
   `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/`;
   `grep -n "config" internal/archrules/arch_test.go`.

Гейт триаж не перезапускал: его исход взят у прохода `autotests` как замер,
снятый на этом прогоне.
