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

68 KiB
Raw Blame History

Триаж ревью — 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 строками 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/me200. В журнале — стартовая 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/me401, в журнале только 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:115godotenv.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.example200, Account created from login header. SELECT email ... WHERE provider_login='victim'"". Строк журнала о занятом адресе — ноль.

    Второй прогон показывает асимметрию правила прямо: два значения Remote-User401; два значения Remote-Email200, и в базу ложится первое (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-Userlen(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/S2SubstitutedHeaderNames не вызвана ни одним тестом (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, инлайн;
    • C4internal/config/test_headers_test.go объявлен package config и импортирует internal/controller/http, ребро запрещено той же правкой в docs/conventions/config.md. nit; отражён оговоркой в Promote candidates, потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
    • C5ValidateTestHeaders возвращается на первом несовпадении вместо 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 как замер, снятый на этом прогоне.