Files
transcriber/openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

726 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Триаж ревью — `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; S1S4; C1C5; R1R3; V1V3 + три свойства
`adversary` без пути; O1). **Осталось в первых двух секциях:** 7.
### План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1S4) |
| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный |
| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1C5) |
| `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` как замер,
снятый на этом прогоне.