- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
68 KiB
Триаж ревью — 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-lint0 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 строками 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:40Level: 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 на ней стоит. Что делаем?- Завести ключ уровня журнала (
[server] log_levelлибо уровень, выводимый из[server] debug). Спека прямо запрещает предохранителю трогать уровень журнала, значит это отдельный ключ и правка спекиaccess— расширение scope и новое имя ключа конфига, а имя ключаCLAUDE.mdотносит к необратимому и требует спросить человека. - Поднять уровень двух строк до
Info— «подстановка сработала» и «заголовка входа нет». Обе штатны и частотны; в бою первая не печатается вовсе (предохранитель выключен), вторая станет строкой на каждый неузнанный запрос. Правка спеки нужна тоже — она уровень назначает поимённо. - Снять требование отладочной строки из спеки и критерий 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, и в нём лежит перечень ключей, которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени секции проходит молча. Что делаем?- Узкая правка в границах задачи: при
[server] debug = trueи пустой секции имитации ронять старт (или писатьWARN), еслиMetaData.Undecoded()называет что-нибудь подauth. Ловит ровно этот класс, не трогает остальной конфиг, требует одного нового абзаца в дельта-спеке. - Правило шире задачи: судить
MetaData.Undecoded()на всём конфиге — непонятый ключ роняет старт с его именем. Закрывает опечатки во всех секциях разом, но это новая норма вdocs/conventions/config.mdи отдельный change: сегодняшние конфиги на сервере могут нести ключи, которых структура уже не знает (постмортем 2 подтвердил, что откат бинарника поверх нового конфига сегодня безопасен именно потому, что лишние ключи молча игнорируются, — правило это свойство снимет). - Ничего не делать в коде, а записать исход строкой в спеке и в
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). Два шага:- Запрос с
Remote-User: squatterиRemote-Email: victim@corp.exampleс доверенного адреса →200.SELECT email FROM users WHERE provider_login='squatter'→victim@corp.example. - Запрос настоящего владельца:
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). Что делаем?- Взять в этот change: три строки в
identity.go+ строка журнала в ветви отката + абзац в требование спеки. Логика ровно та же, что слой подстановки уже применил к себе («Запрос безRemote-User, но сRemote-Emailиначе собрал бы личность из двух источников»), — правило просто не донесено до соседних двадцати строк. - Отдельной задачей, потому что предмет другой и цена сегодня нулевая:
почту никто не читает. Тогда
docs/security.md:71-74надо поправить в этом мердже — фраза «половину этой беды сервис закрывает сам» описывает периметр точнее, чем он есть, и ссылаться на неё нельзя. - Только документ: записать асимметрию как принятую цену, потребовав от
прокси перезаписи всех трёх
Remote-*(требование к контуру уже записано, см. «Недоступно проверке»), и колонку почты не трогать.
- Взять в этот change: три строки в
6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам
- Файл:
internal/controller/http/server.go:27-49(строка 44if 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 проверяет целиком, и раздел уже переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.
Общее, вне обоих списков: история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях, завязка потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще» — ничем из перечисленного конвейер не занимался.
Четыре строки, которых не принесёт ни один проход
- Решения проекта не сверялись.
docs/adr.*— процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит скиллav-dev:doc-healthcheck, а не ревью. В этом изменении есть чему разойтись:ADR-2026-08-22-login-by-trusted-header.mdописывает вход, к которому заведено изъятие, и сверен с ним никем не был. - Записанные наблюдения проекта не использовались.
docs/research.*— тоже процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмаркBenchmarkSubstituteIdentityHeaders(2972 ns/op, 5718 B/op, 17 allocs/op) — замер проходаops, покрытие (test_headers.go100 %,SubstituteIdentityHeaders94,4 %,SubstitutedHeaderNames0,0 %) — выводgo tool cover -funcпроходаautotests, остальные числа — прогоны триажа с приложенными командами. - Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом. Различение «идиоматично против распространено» не спрашивает никто с тех пор, как упразднён проход про идиоматичность.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет. Проход независимой реализации снят по стоимости, а не по замеру; «не знаю, чего не знаю» на этом прогоне не доставал никто.
Метка — 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.
go test ./internal/config/ -run TestTriage -v— три случая разбора TOML (дубль по регистру, опечатка в имени секции, опечатка в имени ключа).go test ./internal/controller/http/ -run TestTriage -v— сквозной сквоттинг почты на настоящей поверхностиAppChainи асимметрия правила многозначности.go build -o <скретчпад>/transcriber ./cmd/transcriber+ два прогона настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг с опечаткой в имени секции и конфиг правильный.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 как замер,
снятый на этом прогоне.