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

12 KiB

Ревью кода — 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 к вредоносному входу; поведение браузера с куками.
  • Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет — проход независимой реализации упразднён решением о стоимости. «Не знаю, чего не знаю» здесь не достаёт никто, и на изменении, замораживающем публичную форму ответов, это дорого.