Files
transcriber/openspec/changes/archive/2026-08-22-trusted-header-login/review/report.md
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

512 lines
46 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.
# Ревью изменения `trusted-header-login`
## Сводка
- **Режим прогона:** по графу. **Метка:** `large`.
*Размер и сложность в задании триажу не переданы* — обоснование разметки
воспроизвести нечем, метка взята как объявленная.
- **База диффа:** `origin/master`. Изменение читалось вместе с непрослеженными
файлами (`cmd/devtools/`, `internal/adapter/repo/pocketbase/identity.go`,
`migrations/202608220001_trusted_header_login.go`,
`internal/controller/http/identity.go`, дельта-спеки change).
- **Состояние гейта:** зелёный целиком, кроме шага `dockerfile``hadolint`
даёт `DL3066` на `Dockerfile:94`. Отказ **унаследован** (воспроизведён на
чистом `origin/master`), долг объявлен строкой в `CLAUDE.md`, раздел «Гейт».
Новым красным шагом это изменение гейт не красит.
- **На входе:** 31 пронумерованная находка проходов + 4 пункта «дешевле
переделать до мерджа» от `architecture` + 1 замер без дефекта от `ops`.
**На выходе:** 3 + 4 = 7 в основных секциях, остальное — ниже, ничего не
выброшено молча.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | дельта-спеки change + `openspec/specs/` | разбор | `specs` | закрыта, 8 находок (5–12); в отчёт ушли 2, ещё 4 — ниже |
| autotests | `CLAUDE.md`, «Гейт» | — | `autotests` | закрыта, 4 находки (1–4); в отчёт не ушла ни одна самостоятельно, одна закрывается правкой №3 |
| conventions | `docs/conventions/` | разбор | `code` | закрыта, 8 находок (15–22); в отчёт ушли 2 |
| architecture | `docs/architecture.md` + `docs/passport.md` | доказательство | `architecture` | закрыта, 2 находки + 4 пункта «до мерджа»; в отчёт ушла 1 |
| security | `docs/security.md` | доказательство | `adversary` | закрыта, 6 находок (23–28); в отчёт ушли 3 |
| operations | `docs/architecture.md` «Эксплуатация» + `docs/database.md` | доказательство | `ops` | закрыта, 3 находки + 1 замер без дефекта; в отчёт не ушла ни одна, старшая — в гипотезах |
- **Тем без отчёта нет.** Тем без дома в плане нет.
- **`basics` не запускался** — своих тем проекта нет, все шесть разобраны
именными проходами.
- **Сигнал о заниженной метке:** `review-code` отработал и возражений по метке
**не подал**; `review-basics` не запускался, поднять метку было некому.
Молчание здесь — молчание одного корректора из двух, а не двух из двух.
---
## Блокирует мердж
### Запрос, отвергнутый ограничителем частоты, всё равно заводит учётную запись
- Файл: `internal/controller/http/identity.go:91`, `internal/controller/http/errors.go:219`
- Severity: major (`critical` не ставлю: инвариант `CLAUDE.md` этим не нарушен, а
путь ведёт к мусору в коллекции, а не к порче чужих данных)
- Confidence: high
- Оракул: **свой падающий тест, прогнан на этом прогоне.** Слой узнавания стоит
на `DefaultLoadAuthTokenMiddlewarePriority + 1` = **−1019**, ограничитель
библиотеки — на **1000** (`apis/middlewares_rate_limit.go:16`), то есть
узнавание отрабатывает **до** ограничителя. Прогон: 120 запросов выбирают
бюджет, следующие 50 запросов новыми логинами получают `429`**все 50**, — а
учётных записей в коллекции становится `2 → 52`.
- Последствие: ограничитель частоты не защищает **ничего** из того, что делает
слой узнавания. Каждый отвергнутый запрос — это как минимум чтение из базы
(поиск по логину идёт на каждом запросе по норме `access`), а с новым именем —
ещё и запись. Учётная запись, заведённая мусором, из сервиса не убирается:
спека `access` называет цену прямо — «слить их или убрать нечем». Барьер
«заголовок ставит прокси» этот путь сужает, но не закрывает: `docs/review.md`,
«Недоступно проверке», записывает поведение прокси как непроверяемое отсюда, и
строить на нём защиту базы значит держать один барьер вместо двух.
- Предложение: перенести слой узнавания на
`apis.DefaultRateLimitMiddlewarePriority + 1` (999), а `RequireUser` — на
`+ 2` (−998). Порядок «токен (−1020) → ограничитель (−1000) → узнавание (−999)
→ требование записи (−998) → предел тела (−990)» сохраняется целиком.
- Найдено проходом: `adversary` (25); подтверждено собственным прогоном триажа.
- Действие: **инлайн**
> **Ответ на первый вопрос задания — перестановка проверена запуском.** С
> приоритетами −999/−998 прогнан **весь** `go test ./...`: единственным красным
> остался мой временный тест на находку про общий бюджет (ниже, №4), все
> существующие проверки пакета `internal/controller/http` — включая оба критерия
> приёмки, победу предъявленного токена над заголовком и «проба здоровья
> учётной записи не заводит» — прошли. Заведение записи после `429` при этом
> пропало: `2 → 2`. Предел тела на −990 действительно остаётся после требования
> учётной записи, «отказ неузнанному до чтения тела» сохраняется.
>
> **Одно последствие перестановки задание не называет, и оно настоящее.** Сейчас
> `RequireUser` (1018) отказывает неузнанному **до** ограничителя, и отказы
> бюджета не тратят. После переноса — тратят: замер на этом прогоне, 125
> запросов без заголовка, и следующий запрос узнанного человека получает `429`
> (до перестановки — `200`). Само по себе это правильнее (счёт отказов и есть
> работа ограничителя), но вместе с общим бюджетом (№4) оно даёт анониму,
> дотянувшемуся до контейнера, выключение сервиса для всех. Поэтому №1 и №4
> едут одной правкой, а не порознь.
### Ключ учётной записи правится рукой в панели, и архив уезжает следующему с этим именем
- Файл: `internal/adapter/repo/pocketbase/migrations/202608220001_trusted_header_login.go:77-82`,
`internal/adapter/repo/pocketbase/panel.go:33-34`
- Severity: major
- Confidence: high
- Оракул: дословное требование дельта-спеки
`openspec/changes/trusted-header-login/specs/access/spec.md`: «**Ключ учётной
записи MUST не правиться ничем, кроме заведения самим сервисом.** Ни запросом
снаружи, ни рукой в панели». Плюс
`grep -rn "OnRecordUpdateRequest" --include=*.go internal/ cmd/` → единственная
привязка стоит на `migrations.RecordsCollection`, на `users` хука нет. Шаг
схемы закрывает правила API (`UpdateRule = nil`), а панель работает
суперпользователем — это записано комментарием самого шага, строки 48–50.
- Последствие: переписанный в панели `provider_login` отдаёт весь архив прежнего
владельца тому, кто придёт с этим именем следующим. Владелец записи
назначается один раз и не меняется — вернуть архив нечем. Правка своя,
сознательная, но необратимая и **молчаливая**: журнала событий у коллекции
пользователей нет.
- Предложение: развилка ниже.
- Найдено проходом: `specs` (6)
- Действие: **развилка**
> **Вопрос владельцу.** Требование спеки «ключ не правится рукой в панели»
> сегодня не держит ничто: правила API закрыты, но панель ходит
> суперпользователем. Три варианта:
> **(А)** завести хук `OnRecordUpdateRequest(users)`, отвергающий смену
> **непустого** `provider_login` — в проекте уже есть тот же приём для записей
> (`pbrepo.BindPanelRules`), цена — десяток строк и проверка;
> **(Б)** переписать требование, назвав панель доверенной и записав цену
> («ключ правится владельцем сервиса, откатить правку нечем»);
> **(В)** оставить как есть — тогда спека и код расходятся молча, и это
> расхождение переживёт мердж.
### Заведение учётной записи не оставляет в журнале ни строки, а поломка контура пишется на уровне, которого в бою нет
- Файл: `internal/controller/http/identity.go:101-108,128-151`,
`internal/adapter/repo/pocketbase/identity.go:52-87`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос новым логином с доверенного адреса →
ответ `200`, учётная запись заведена, перехваченный журнал — **пустая строка**
(`""`). Дословное требование дельта-спеки `access`: «Исход узнавания MUST
оставлять строку журнала — и когда заголовок пришёл с недоверенного адреса, и
**когда учётная запись заведена**. […] Строка несёт адрес пира и идентификатор
учётной записи». Второй симптом: ветвь «более одного значения `Remote-User`»
(строки 103–106) пишет `Debug`, боевой уровень — `Info`
(`docs/conventions/logging.md`, «Расхождение: уровень зашит константой»).
- Последствие: сервис заводит учётные записи молча — владелец не отличит «никто
не заходил» от «завелось двадцать записей», а по спорной учётной записи не
скажет, когда и с какого адреса она появилась. Хуже второе: `docs/security.md`
называет прокси, добавляющий заголовок вместо замены, **главным** барьером
контура, и половину этой беды сервис закрывает сам — но закрывает **невидимо**:
строка о двух значениях в боевом журнале не появляется вовсе. Поломка контура,
которую сервис поймал, владельцу неотличима от тишины.
- Предложение: `logger.Info` на исходе «учётная запись заведена» — с адресом
пира и идентификатором записи, **без** значения заголовка (инвариант
«Содержимое записи остаётся приватным» и требование спеки «MUST не нести
значения заголовка»); уровень ветви «два значения» поднять до `Warn` — ровно
как у ветви недоверенного пира, по той же причине и с той же ценой.
- Найдено проходами: `specs` (5) и `adversary` (26) — две находки об одной
причине, слиты; **оракула у согласия проходов нет, оракул свой**.
- Действие: **инлайн**
---
## Стоит исправить сейчас
### Бюджет ограничителя частоты общий на весь сервис: один человек выключает сервис остальным
- Файл: `internal/controller/http/rate_limit.go:20-23`, `internal/controller/http/app.go:43-63`,
`internal/config/config.go:91-96`, `cmd/transcriber/main.go:238-246`
- Severity: major
- Confidence: high
- Оракул: **свой падающий тест.** 125 запросов первого человека выбирают бюджет,
**первый** запрос второго человека получает `429`. Причина — в исходниках
библиотеки: `checkRateLimit` берёт ключом `e.RealIP()`
(`apis/middlewares_rate_limit.go`), а `RealIP()` при пустом
`Settings.TrustedProxy.Headers` откатывается к адресу пира
(`core/event_request.go:40-75`). Пир теперь **всегда** обратный прокси.
- Последствие: объявленная сервисом частота опроса выводится из доли бюджета
(`pollBudgetShare = 8`, `PollIntervalMs = 4000`) в расчёте на то, что бюджет
делят немногие. Делят его **все**: восемь одновременных опросов выбирают его
целиком, и девятый человек получает `429` на пустом месте. Отказ шумный, но
причина невидима — по журналу он неотличим от собственной активности.
- **Дефект старше этой задачи** (правило заведено `spa-skeleton` 2026-08-15,
прокси перед сервисом стоял и тогда). Уточнение к формулировке прохода:
`docs/database.md:344` и `app.go:43-49` **не** утверждают обратного — там
прямо написано «бюджет считается по адресу спрашивающего, а не по учётной
записи», и даже назван случай «двое за одним домашним адресом делят его
пополам». Сломалось не правило, а **основание** правила: «адрес
спрашивающего» выродился в один адрес на всех, и число 120/60 выбиралось не
под это.
- Найдено проходами: `architecture` (13) и `adversary` (23); подтверждено
собственным прогоном.
- Действие: **развилка**
> **Ответ на второй вопрос задания: чинить здесь.** Не потому, что дефект
> этой задачи — он не её, — а потому, что правка №1 делает его достижимым для
> **анонима**: после переноса `RequireUser` за ограничитель отказы неузнанному
> начинают тратить общий бюджет (замерено на этом прогоне: 125 анонимных
> запросов → узнанный человек получает `429`). Мерджить №1 без ответа на №4
> значит завести новый путь к отказу сервиса.
>
> **Вопрос владельцу.** Чем считать бюджет ограничителя, когда весь трафик
> приходит с одного адреса:
> **(А)** заполнять `Settings.TrustedProxy.Headers` при подъёме (там же, где
> `ApplyAppRateLimit`) — тогда ключом станет адрес человека из
> `X-Forwarded-For`. Доверие к этому заголовку той же природы, что доверие к
> `Remote-User`, и опирается на тот же перечень адресов; цена — ещё одно
> требование к контуру, которое отсюда не проверить;
> **(Б)** признать бюджет общим на сервис: поднять числа под ожидаемое число
> людей, переписать `docs/database.md` и обоснование `pollBudgetShare`;
> **(В)** отложить в урожай и мерджить №1 с известным ухудшением — тогда
> анонимный поток через прокси выключает приложение всем.
### Негодное значение `Remote-Name` запирает человека в сервисе навсегда пятисоткой
- Файл: `internal/adapter/repo/pocketbase/identity.go:71-87,147-159`,
`internal/controller/http/identity.go:133-147`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Запрос с доверенного адреса, `Remote-User: namebearer`,
`Remote-Name` из 5000 знаков → ответ `500`
`{"error_code":"internal","message":"Внутренняя ошибка сервиса"}`, в журнале
`level=ERROR msg="Failed to resolve account by login header" error="failed to
create user account: name: Must be no more than 255 character(s).."`.
Значение заголовка в ответ и в журнал при этом **не** уехало — инвариант
приватности цел.
- Последствие: приёму подвергается только логин (`AcceptProviderLogin`), а имя и
почта уезжают в колонку как есть. Человек, чьё имя у провайдера длиннее 255
знаков, получает `500` на **каждом** запросе и в сервис не попадёт никогда;
владелец получает `ERROR` на каждый такой запрос. Смежный симптом той же
причины: разбор отказа судит по **наличию колонки** в `validation.Errors`, а не
по причине отказа — негодный адрес почты неотличим от занятого, и запись молча
заводится без почты вместо честного разбора.
- Предложение: привести имя и почту к годным значениям в одном месте с логином —
имя усечь до предела колонки, негодную почту не ставить вовсе. Этой же правкой
закрывается непокрытая ветвь «отказ хранилища → отказ сервиса»
(`identity.go:144-146`), которую `autotests` называет единственным намеренным
исключением из «слой отказа не выдаёт»: у неё появляется достижимый и
проверяемый вход.
- Найдено проходами: `specs` (7, 10), `code` (15, 16), `adversary` (24) — пять
формулировок, две причины, одно место правки; слиты. Согласие пяти проходов
`Confidence` не повышает — оракул свой.
- Действие: **инлайн**
### Образец конфига велит верить всей подсети docker: любой контейнер на хосте входит под любым именем
- Файл: `config.example.toml:69-77`
- Severity: major
- Confidence: medium (путь не построен: второго контейнера в прогоне нет)
- Оракул: положение `docs/security.md:109` дословно — источник заголовков есть
«Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного
адреса**». Образец задаёт этим адресом `172.16.0.0/12`, то есть весь
умолчательный диапазон docker: под него попадает любой контейнер на хосте, а
не только Caddy.
- Последствие: барьер узнавания держится **только** на адресе пира (кук и
токенов сервис не выдаёт). Любой сосед по хосту — а хост держит пет-проекты, а
не один сервис — ставит `Remote-User: <чужой логин>` и получает чужой архив
целиком, молча. Класс риска в `security.md` назван, ширина перечня — нет.
- Предложение: развилка ниже.
- Найдено проходом: `adversary` (27)
- Действие: **развилка**
> **Вопрос владельцу.** Чем сузить перечень доверенных адресов до самого
> прокси:
> **(А)** выделенная сеть compose с фиксированной подсетью под пару
> «прокси ↔ сервис» — правка живёт в `pet-project-server`, здесь остаётся
> значение образца и строка требования к контуру;
> **(Б)** статический адрес прокси в сети docker — то же, но проще и хрупче;
> **(В)** принять `/12` как цену и записать её строкой в `docs/security.md`
> рядом с уже названным классом — тогда это осознанная цена, а не умолчание
> образца.
### Три конвенции проекта описывают снятый механизм, и одна противоречит себе внутри файла
- Файл: `docs/conventions/web-ui.md:107-109`, `docs/conventions/config.md:60-64` против `:99`,
`docs/conventions/logging.md:34-37`
- Severity: minor
- Confidence: high
- Оракул: чтение файлов на этом прогоне. `web-ui.md`: «**Сессия живёт кукой
`transcriber_session`**» — кук сервис больше не ставит вовсе. `config.md`
строка 60–64: «Секретов в этой секции больше нет», строка 99 в том же файле
по-прежнему перечисляет `auth.client_secret` среди секретов. `logging.md`
строки 34–37 описывают расхождение у пакета `cmd/oidcstub`, который этим же
изменением удалён (`git status``D cmd/oidcstub/main.go`).
- Последствие: конвенции — канон проекта, по ним работает следующая задача.
Запись про куку заставит следующего агента искать куку и объяснять её
отсутствие; запись про `client_secret` заставит охранять секрет, которого нет.
Машина этого не ловит: согласованность документов между собой и с кодом судят
агенты, и звать их надо руками (`CLAUDE.md`, «Гейт»).
- Предложение: снять три записи, поправить перечень секретов в `config.md`.
- Найдено проходом: `code` (20)
- Действие: **инлайн**
---
## Гипотезы без доказательства
Понижено и не влезло в потолок; каждая с причиной понижения.
- **Откат бинаря поверх шага схемы `202608220001` обрывает вход** (`ops`, 29;
было `major`). Оракул у прохода настоящий — падающий тест на восстановленном
старом коде во временном worktree, `401` вместо `302`. Понижаю до `minor`
**по стадии и по перекрытию**: (1) стадия — стройка, сервис на сервере
остановлен, данных нет, откатываться не с чего; (2) `docs/architecture.md:142`
уже объявляет откат неработающим начиная с шага `202608140002` — то есть любой
бинарь старше 2026-08-14 и так не поднимается; новое окно отката — только
версии между 14 и 22 августа, ни одна из которых не выкладывалась; (3) старый
бинарь на новом конфиге и вовсе не стартует, если владелец убрал ключи OIDC по
процедуре из «Эксплуатации» — тогда отказ громкий, а не молчаливый.
**Ответ на третий вопрос задания: да, стадия меняет серьёзность.** Правка тем
не менее дешёвая и уместная — строка в `docs/architecture.md`, «Эксплуатация»,
рядом с уже стоящей про `202608140002`: не потому что откат сегодня возможен, а
потому что порог перехода там принято называть прямо.
- **Уникальный индекс по `provider_login` не частичный** (`code`, 17). Оракул
прохода — исходники библиотеки (`core/collection_model.go:648-671`, индекс
собирается без `WHERE`, сосед по коллекции — `WHERE email != ''`). Понижаю:
сервис пустого логина в колонку не кладёт никогда (`AcceptProviderLogin`
отвергает), коллекция пользователей на выкладке пуста, и последствие сводится
к «в панели рукой не завести вторую запись без логина». Промах будущего, не
дефект сегодня.
- **Предел логина считается в байтах, колонка — в знаках** (`specs`, 8;
`code`, 18). Проверено чтением: `len(login) > 255` в
`identity.go:174` против `Max: 255` у текстовой колонки. Понижаю: перекос
только в сторону строгости, кириллический логин длиннее 127 знаков у Authelia
не встречается, следа `Debug` хватает. Пути нет — гипотеза.
- **Склейка значений заголовка запятой обходит защиту «два значения»**
(`adversary`, 28). Пути нет и построить его отсюда нечем: `net/http` заголовки
запятой не сливает, а прокси, который сливает, — ровно тот класс, который
`docs/review.md` относит к «не проверит ни один проход». Последствие при этом
было бы неприятным и тихим (человек попадает в **новую** пустую учётную
запись вместо своей), поэтому строка оставлена, а не выброшена.
- **Откат шага схемы не восстанавливает `OAuth2.Enabled` и минирует записи без
почты** (`specs`, 9; `ops`, 30). Оракул у `ops` есть — прогон на временной
базе, `email: cannot be blank`. Понижаю по тому же основанию, что и №29: `down`
на сервере не зовётся, а применённый шаг не переписывается по инварианту.
- **Отказ хранилища при узнавании не покрыт ничем** (`autotests`, 2). Не
выведена отдельной строкой: правка про негодное имя даёт этой ветви
достижимый вход, и тест на неё пишется той же правкой. Если владелец выберет
не чинить негодное имя — эта строка возвращается самостоятельной находкой.
- **Непокрытые ветви `retryAfterConflict` и `web/src/api.ts`** (`autotests`, 1,
3, 12). Понижаю по записанному решению проекта: `CLAUDE.md`, «Гейт» —
«покрытие изменённых строк не считается ничем». Своего `api.test.ts` у нового
`web/src/api.ts` действительно нет (проверено `ls web/src/`), и это стоит
завести — но это задача, а не блокирующая находка.
---
## Promote candidates
Претензии на правило, а не на этот код.
- **Поле `peer` заведено мимо словаря имён журнала** (`code`, 21).
`docs/conventions/logging.md:99` требует точечной иерархии для системных
доменов (`http.*`, `ext.*`, `webapp.*`), а в файле уже стоит *Расхождение* с
перечнем самозаведённых имён. Кандидат в конвенцию: внести `http.peer_addr` в
таблицу полей — тогда следующий агент возьмёт имя из словаря, а не сочинит
третье.
- **Третий способ вывода в репозитории** (`code`, 22). `cmd/devtools` печатает
через stdlib `log`, тогда как `logging.md` знает два. Записанной конвенции об
оснастке у проекта нет — значит это кандидат в правило, а не нарушение.
- **Единственный дом имён заголовков `Remote-*`** (`architecture`, 14).
`cmd/devtools/main.go:92-97` пишет `"Remote-User"`, `"Remote-Name"`,
`"Remote-Email"` своими литералами, тогда как нормативные константы лежат в
`internal/controller/http/identity.go:23-27`. Правка на две строки, но правило
общее — «нормативное имя объявляется один раз» — и его стоит записать, а не
чинить точечно. Смежный вопрос `docs/review.md`, «Вопросы по темам»: не завёл
ли инструмент разработчика второй дом тому, что уже есть в проверках.
- **Второй словарь текстов на клиенте** (`code`, 19). Половина находки —
ложноположительная: `docs/conventions/web-ui.md:118` **сам** разрешает
клиентский текст «связи нет» состоянием, а не ошибкой. Настоящий остаток —
расхождение текстов: сервер на `401` отвечает «Требуется вход» про вход,
которого у сервиса больше нет, а экран показывает свой текст поверх. Кандидат
в правило: конвенция сегодня не различает «текст под код ответа» и «текст под
состояние клиента», и на этом различии находка и разошлась.
---
## Урожай
Настоящее, но не для этого мерджа.
- **Бюджет ограничителя частоты по человеку, а не по адресу** — если владелец
выберет вариант (В) на развилке №4.
- **Секция конфига называется `[auth]` при отсутствии всякого входа**
(`architecture`). Имя ключа конфига — из перечня необратимого в `CLAUDE.md`
(«спрашивается у человека всегда»), но **сегодня стадия стройки**: на сервере
ничего нет, и переименование стоит ноль. После первой выкладки — уже решение
человека. Если переименовывать, то сейчас.
- **Колонка почты без единого читателя** (`architecture`). Почта пишется при
заведении и не читается ничем. Не дефект: спека объявляет её необязательной и
берёт «при заведении». Но колонка без читателя — долг, который стоит либо
оправдать спекой, либо снять.
- **Корень `/auth` перестал быть зарезервированным** (`architecture`). Проверено:
упоминаний `/auth` в `app.go` больше нет. Освободившееся адресное пространство
стоит либо занять, либо назвать свободным в спеке `webapp`.
- **Свой тест у `web/src/api.ts`** — файл новый, разбор отказов в нём есть,
проверки нет.
- **Строка про порог отката в `docs/architecture.md`, «Эксплуатация»** — см.
гипотезу №29.
---
## Границы покрытия
### План: темы, дома, глубины
Воспроизведён таблицей в сводке выше целиком. Тем без дома в плане нет, тем без
отчёта нет.
### Что запускалось
- Режим: по графу, метка `large`. Запущены `specs`, `autotests`, `code`,
`architecture`, `adversary`, `ops`.
- **`basics` не запускался** — своих тем проекта у него на этом прогоне нет, все
шесть тем разобраны именными проходами. Следствие: сигнал о заниженной метке
мог подать только `review-code`, и он его не подал.
### Чего не хватило триажу, и это находки о прогоне
- **Блоков «Coverage of this pass» в сырых выводах нет ни у одного прохода.**
Контракт находок требует их у каждого, и сводить мне было нечего: строку «что
этот проход не мог проверить в принципе» я по каждому проходу воспроизвести не
могу. Ниже — только то, что выводится из плана и из документов проекта.
- **Сработавшие потолки ни один проход не назвал.** Сколько находок проход
показал, каков был его потолок и что осталось за срезом — не сообщено никем.
Значит утверждать «проход показал всё, что нашёл» нельзя ни про один из шести.
- **Размер и сложность изменения триажу не переданы** — только метка.
Обоснование разметки в отчёте воспроизвести нечем.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке». **Два списка, и они не сливаются.**
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: **поведение настоящей Authelia и правило обратного прокси на домен
сервиса.** С 2026-08-22 от прокси зависит **весь** барьер: он обязан заголовки
`Remote-*` перезаписывать, а не пропускать пришедшие. Правило живёт в
`pet-project-server`, и отсюда его не проверить. Прямо на этом висят находки
№1 (насколько узок путь к заведению мусорных записей), №6 (ширина перечня
доверенных адресов) и гипотеза про склейку запятой;
- `security`: поведение браузера с куками — своих кук сервис больше не ставит,
класс сузился до кук панели хранилища.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
`ADR-2026-08-11-stub-adapters-in-tests.md`;
- **работа сервиса с настоящими внешними собеседниками** в остатке: за настоящие
SpeechKit и Object Storage живой прогон не отвечает — ключи выдуманные,
распознавание подменяется в коде. Вход при этом с 2026-08-22 живой прогон
проверяет целиком (`cmd/devtools proxy`), и это сдвиг в другую сторону.
Плюс общее, что не проверяет ни один прогон: история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- `docs/review.md`, «Типовые ложноположительные» — **есть и не пуст**, отсев по
нему выполнен. Одна находка (`code`, 19) отсеяна наполовину по общим
критериям, а не по нему: разрешение клиентского текста для состояния «связи
нет» стоит в `docs/conventions/web-ui.md`, а не в разделе
ложноположительных.
- `CLAUDE.md`, «Инварианты» — есть; ни одна находка этого прогона до `critical`
по этому основанию не поднята, потому что ни одна инвариант не нарушает:
проверено поимённо для приватности содержимого (значение заголовка в журнал и
в ответ не уходит — замерено), для секрета в конфиге (секретов в секции
`[auth]` больше нет) и для запрета переписывать применённый шаг схемы.
- **Прочих пробелов в документах проходы не назвали** — но назвать их было
некому: блоков границ у проходов нет (см. выше). Отсутствие строки здесь не
означает, что документов хватило.
### Четыре строки, которых не принёс ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Изменение заводит `ADR-2026-08-22-login-by-trusted-header.md`,
и сверку его с остальными записанными решениями — включая те, что это
изменение помечает (`ADR-2026-08-12-oidc-exchange-via-own-route`,
`ADR-2026-08-12-session-without-refresh`), — конвейер ревью не делал.
Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, и звать его надо руками.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в этом отчёте снято на этом прогоне: приоритеты
слоёв взяты из исходников библиотеки в `GOMODCACHE`, счёт учётных записей и
коды ответов — из прогонов, приложенных к находкам.
3. **Поимённая сверка с руководствами по стилю Go и Vue не задавалась ни одним
проходом.** Различение «идиоматично против распространено» на этом прогоне не
спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
«Не знаю, чего не знаю» про схему узнавания по доверенному заголовку никто не
доставал.
Пятой строки — про метку `small` — на этом прогоне нет: метка `large`, дома тем
`security`, `operations` и `architecture` открывались.
### Что делал сам триаж
- Прочитаны: дифф изменения, `CLAUDE.md`, `docs/review.md` целиком,
`docs/security.md`, `docs/architecture.md` «Эксплуатация», `docs/database.md`
«Настройки с числовым значением», три конвенции, дельта-спека `access`,
исходники `pocketbase@v0.39.10` (`apis/middlewares*.go`,
`core/event_request.go`).
- Запущено во временном файле проверок пакета `internal/controller/http`
(удалён после прогона, дерево восстановлено, `go build ./...` и
`go test ./internal/controller/http ./internal/adapter/repo/pocketbase`
зелёные):
- заведение учётных записей запросами, ответившими `429` (`2 → 52`);
- общий бюджет ограничителя между двумя людьми;
- `500` и `ERROR` на длинном `Remote-Name`;
- пустой журнал на заведении учётной записи;
- расход бюджета анонимными отказами до и после перестановки приоритетов
(`200``429`).
- Перестановка приоритетов на −999/−998 применена **временно** и откачена;
на ней прогнан весь `go test ./...`. Код изменения не правился.