# Триаж ревью — `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=`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`, `Server.Debug=true len(TestHeaders)=0 substituting=false`, `ValidateTestHeaders err = `. Контроль: та же опечатка **внутри** секции (`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` как замер, снятый на этом прогоне.