вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
@@ -0,0 +1,511 @@
# Ревью изменения `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 ./...`. Код изменения не правился.