Files
transcriber/openspec/changes/archive/2026-08-15-app-json-contract/review/code-review.md
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

102 lines
12 KiB
Markdown

# Ревью кода — app-json-contract
Метка `large`, режим «по графу». Состав: `autotests`, `specs`, `code`,
`architecture`, `adversary`, `ops`, `triage`. `basics` не запускался — своих тем
проекта нет, все темы ядра разобраны именными проходами.
## План против исхода
| Тема | Дом | Глубина | Кто закрыл | Исход |
| --- | --- | --- | --- | --- |
| `requirements` | `openspec/specs/` + дельты | разбор | `specs` | 7 находок |
| `autotests` | `CLAUDE.md`, «Гейт» | доказательство | `autotests` | гейт зелёный, 4 находки об отсутствующей верификации |
| `conventions` | `docs/conventions/` | разбор | `code` | 11 находок, потолок конвенционной половины сработал (4 из 4, за срезом двое) |
| `architecture` | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | 3 находки, потолок сработал |
| `security` | `docs/security.md` | доказательство | `adversary` | 3 построенных пути, 3 свойства |
| `operations` | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | 2 постмортема с замерами, 1 закрытая гипотеза |
**Проход `ops` едва не остался незапущенным** — он ждал освобождения машины
после враждебного прохода, и оркестратор его не позвал. Поймал это триаж
сверкой плана с исходом; проход запущен и вернул две находки уровня `major`,
обе с замерами. Это дефект прогона, а не темы, и он записан здесь.
## Что найдено и починено
Каждая правка закрыта оракулом; тесты названы поимённо.
| Находка | Проходы | Правка и оракул |
| --- | --- | --- |
| `413`, `429` и неизвестный путь под `/app` уходили телом библиотеки — мимо единой формы, ради которой заведена задача | architecture, code, specs, adversary | слой `OneErrorForm` плюс свой перехват неизвестного пути; `TestTooLargeOnTheRealPath`, `TestRateLimitRefusalGoesThroughOneErrorForm`, `TestUnknownAddressUnderAppRootUsesOneErrorForm` |
| объявленная частота опроса равнялась **всему** бюджету ограничителя | architecture, code, specs, adversary | частота выведена из доли бюджета; `TestPollIntervalLeavesBudgetHeadroom` |
| перечень доступных видов строился по ссылке, а не по содержимому: карточка обещала текст, которого адрес не отдал бы никогда | specs | перечень строится по содержимому; `TestEmptyTextIsNotAnAvailableView` |
| поле перечня пропадало из тела вместо пустого перечня | specs, code | поле стало указателем на срез и присутствует всегда; `TestRecordCard_NoTextYetGivesEmptyViews`, `TestCardAndPageItemShareOneShape` |
| ключ страницы с негодным временем принимался молча и отдавал пустой архив при непустом счётчике | specs, code | время разбирается и приводится к виду хранилища; `TestList_MalformedCursorIsRejected` (пять случаев) |
| выборка с непозитивным пределом роняла процесс обращением по индексу −1 | code | предел приводится к умолчанию в самом адаптере |
| неизвестное состояние отбора отдавало весь архив вместо отказа | code | ветвь отказа вместо молчаливого «без сужения» |
| разрешение тем шло без сужения владельцем | adversary | сужение добавлено; `TestForeignTopicDoesNotResolve` |
| длинное расширение из имени отправителя давало `500` и строку `ERROR` в журнале | adversary | потолок длины расширения; `TestIntake_AbsurdExtensionDoesNotBecomeInternalError` |
| `401` собирался руками мимо единой точки — на первой строке её собственной таблицы | code | заведён свой признак, ответ идёт через отображение |
| разрешение тем и перечень видов не исполнялись под тестом ни разу | autotests, triage | `TestTopicsResolveToNames`, `TestAvailableViewsCoverEveryKind` |
| ветвь замены правила ограничителя не исполнялась: правила копились бы с каждой выкладкой | autotests | повторный вызов в `TestRateLimitRuleCoversAppRoot` |
| **страница владельца сканировала весь архив сервиса** — замер: рост архива в 40 раз растил время страницы в 20–30 раз при неизменных сорока его записях | ops | два индекса в том же шаге схемы; `EXPLAIN QUERY PLAN` показывает покрывающий поиск вместо полного сканирования |
| **включение ограничителя схлопнуло все внутренние обмены OIDC-кода в один счётчик** — третий вход в пределах трёх секунд отвергался с текстом «Войти не удалось» | ops | внутреннему запросу задан адрес; регрессия внесена самим изменением и им же закрыта |
## Решение человека по ходу ревью
**Норма «непрочитанная длительность отличима от нулевой» снята.** Проверено
прогоном: числовая колонка хранилища пустого значения не держит, пустое кладётся
нулём, и ветвь кода была недостижима. Отличимость стоила бы четвёртой колонки
либо текстового типа у чисел; платить не за что — обе величины ставит приём и
ставит всегда, а запись с непрочитанными метаданными отвергается отказом и не
заводится вовсе. Правлены спека `storage`, `docs/database.md`, комментарии и
критерий приёмки; мёртвая ветвь убрана.
## Урожай — реальное, но не для этого мерджа
- **Ключ ограничителя частоты — адрес, а не учётная запись.** Свойство
библиотеки: двое за одним адресом делят бюджет. Арифметика против высокой
цены — объявленная частота даёт восьмерых одновременно опрашивающих на адрес.
Оракула на ущерб нет и быть не может: реального профиля нагрузки проект не
знает.
- **`TrustedProxy.Headers` не настроен нигде**, а сервис публикуется через
обратный прокси. Значит бюджет ограничителя считается по адресу прокси, то
есть общий на всех посетителей. Проверено чтением; на живом прокси не
воспроизводилось — его конфигурация вне репозитория.
- **Отказ ограничителя не виден в журнале контейнера** — канале, который
архитектура называет основным: встроенный слой стоит раньше нашего журнала
запросов. Виден только во внутренней таблице хранилища.
- **Уборка имени файла снимает только категорию Cc.** Разворот направления и
невидимые пробелы доезжают до колонки, до ответа и до имени файла на диске.
Видит это владелец записи и владелец сервиса.
- **Точность длительности.** Колонка названа в миллисекундах, а источник даёт
целые секунды: значение всегда кратно тысяче.
- **Род узла «читающий обработчик и список» в `docs/review.md` не заведён** —
свойства вроде устойчивости окна и потолка страницы там не спрашивает никто.
## Границы покрытия
- Метка `large`, режим «по графу», проходов семь. Потолки сработали у `code`
(конвенционная половина, 4 из 4) и у `architecture` (3 из 3) — оба объявили.
- **`ops` едва не остался незапущенным**, и поймал это только триаж. Строка
оставлена намеренно: пропуск был неотличим от прохода без находок.
- **Триаж получил дайджест оркестратора, а не сырые выводы проходов** — часть
их находок дошла до него уже починенной. Дедупликация выполнена над
дайджестом; находка, которую проход показал, а оркестратор не перечислил, для
триажа была невидима.
- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`)
прогоном не открывались: процессные документы. Расхождение изменения с
записанным решением ловится сверкой документации, а не ревью.
- Все числа отчёта сняты на этом прогоне: время страницы на 5k/50k/200k строк,
бюджет ограничителя, коды и тела ответов.
- Живой прогон сценария вошедшего локально невозможен по устройству проекта:
сессию выдаёт только провайдер OIDC, а локальный запуск наружу не ходит.
Этот путь закрыт машиной в тестах, через настоящий роутер и настоящее
хранилище.
- Не проверено ничем: поведение настоящих SpeechKit, Object Storage, Authelia и
обратного прокси; реальный профиль нагрузки и реальный размер архива;
стойкость `ffmpeg` к вредоносному входу; поведение браузера с куками.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет —
проход независимой реализации упразднён решением о стоимости. «Не знаю, чего
не знаю» здесь не достаёт никто, и на изменении, замораживающем публичную
форму ответов, это дорого.