приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
@@ -0,0 +1,101 @@
# Ревью кода — 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` к вредоносному входу; поведение браузера с куками.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет —
проход независимой реализации упразднён решением о стоимости. «Не знаю, чего
не знаю» здесь не достаёт никто, и на изменении, замораживающем публичную
форму ответов, это дорого.
@@ -0,0 +1,97 @@
# Ревью дизайна — app-json-contract
Метка `large`, режим «по графу». Разметка: размер крупное, сложность
незнакомое; метку назвал агент `review-scope` до написания артефактов.
Состав по метке: `specs` (режим «дизайн ДО кода»), `rubric`, `architecture` плюс
вопрос автору о трёх формах решения. Гейта на этой стадии нет — кода не
существует; триажа нет — сток стадии это отработка замечаний.
## Что найдено и что с этим сделано
Находок пятнадцать на три прохода, пересечения сведены.
### Отработано правкой спек и дизайна
| Находка | Проход | Правка |
| --- | --- | --- |
| Инвариант «запись не теряется молча» и два требования `pipeline` ссылались на убранный опрос готовности | specs | заведена дельта `pipeline`; карточка обязана нести рубеж, признак остановки и причину; в план добавлена правка `CLAUDE.md` |
| Состав полей карточки не нормирован, хотя на него ссылается `intake` | specs | карточка = элемент страницы плюс перечень доступных видов; имена полей названы поимённо |
| Перечень значений рубежа потерян вместе с убранным требованием | specs | возвращён нормой «принадлежит перечню рубежей конвейера», без перечисления порознь |
| Коды отказа на адресах чтения не заказаны; потерян запрет на разницу ответов без сессии | specs | перечень кодов закрыт и назван; `401` до всякого чтения записи; сценарий возвращён |
| «Вид текста» в `archive` значил форму показа, в `storage` — вид; вычитанный текст недостижим | specs | один закрытый перечень `transcript`/`literary`/`replicas` |
| Признак наличия текста булев, а состояние «текст есть, реплик нет» достижимо | rubric | перечень доступных видов вместо признака |
| «Текста ещё нет» без названного кода сливалось бы с `404` чужой записи | rubric, specs | код `409`, сценарий на отличие от `404` |
| Тело отказа несло только русскую фразу | rubric | два поля: машиночитаемый код из закрытого перечня и сообщение |
| Имена и единицы двух колонок из трёх не названы перед необратимым шагом схемы | rubric, architecture | `original_filename`, `duration_ms`, `size_bytes`; неизвестная длительность отличима от нулевой |
| Потолок размера страницы не назван — параметр обходит постраничность | rubric, specs | умолчание, потолок, усечение, `400` на негодное значение |
| Отказ по превышению размера тела шёл мимо единой формы | rubric | своя доменная ветвь, код `413`, предел в теле числом |
| Имена полей ответа не названы нигде | architecture | названы; форма страницы взята той же, какой её отдаёт хранилище |
| Отбор «в работе» — второй толкователь рубежей, остановленная запись выпадала из обеих половин | architecture, specs | три состояния вместо двух; предикаты выводятся из дескриптора рубежа; правило `archrules` на нового потребителя |
| Ветви `401`, `403`, `413` отсутствуют в таблице `docs/conventions/errors.md`, объявленной источником единой точки | architecture | шаг плана на правку конвенции |
| Два адреса обещаны дизайном, но не названы спекой | specs | обещание снято: названным считается то, что стоит требованием |
### Ушло на чекпоинт человеку
Три развилки — каждая расходится с тем, что владелец назвал в постановке, и
каждая необратима после мерджа.
1. **Длительность и размер колонками записи против батч-разрешения строки
файла.** Обе величины уже лежат строкой файла; довод «по строке на запись
списка» опровергается собственным шагом плана — темы разрешаются одним
запросом на страницу. Цена ошибки: две вечные колонки-копии в применённом шаге
схемы, равенство которых не держит ничто.
2. **Перечень известных расширений в `GET /app/config`.** У сервиса нет понятия
«принимаемые форматы» — единственный такой перечень сужает метку метрики и
имеет другой смысл. Приложение прочитает перечень как «что можно загружать» и
станет единственным местом, где это правило существует.
3. **Постраничное чтение номером страницы против курсора.** Приём пишет в голову
той же ленты, которую читает список: запись, заведённая между двумя
страницами, сдвигает окно — один элемент придёт дважды, другой не придёт
никогда, и оба раза молча.
## Второй круг: разметка и сверка после чекпоинта
Правки после чекпоинта тронули дельта-спеки, поэтому повторены разметка и та
часть ревью дизайна, которой правка касается. Рубрика и архитектурный проход не
перезапускались намеренно: принятые правки — их же собственные рекомендации, и
судить их своим отчётом они не могут.
**Разметка не сдвинулась** — крупное, незнакомое, `large`, теми же пятью
источниками. Правка синхронизировала спеки с решениями, уже стоявшими в дизайне,
а не добавила площадь или неизвестность.
**Повторная сверка дала пять находок, все отработаны:**
| Находка | Правка |
| --- | --- |
| `MODIFIED` требования `pipeline` вырезало обоснование двух сторожей — задача `failure-verdict-vs-retry` прочла бы урезанную норму | предложение возвращено целиком, сменён только держатель нормы |
| Критерии приёмки остались на прежней модели текста и вернули бы в код две уже закрытые находки: булев признак наличия текста и «тот же вид репликами»; оракул формы сравнивал не ту пару | критерии и шаги плана приведены к спеке, правка помечена в самом критерии |
| Половина имён публичного контракта осталась бы за кодом — поля тела отказа и сам перечень кодов, поля пределов и «кто вошёл», имя признака повтора, имена параметров запроса | все названы спекой поимённо |
| Два предела из объявляемых не имели проверяемого источника; имя «частота опроса готовности» протухало вместе с убираемым адресом | частота выведена из настройки ограничителя, перечень расширений — из меток метрики за вычетом `audio`, предел переименован в «частоту опроса карточки» |
| Паспорт, модель угроз и архитектура остались бы описывать убранные адреса, и плана правки на них не было | заведены три шага плана |
Второго чекпоинта не было, и это осознанно: ни одна из пяти находок не меняла
решения, принятого человеком, — все они приводили спеки и план в согласие с уже
принятым.
Что осталось названным, но не закрытым: имя капабилити `archive` совпадает
словом с каталогом заархивированных change и с тем, как паспорт зовёт весь
сервис. Имя оставлено — каталоги разные, слово в проекте своё.
## Границы покрытия стадии
- Метка `large`, режим «по графу»; проходов три, все вернулись, потолок не
срабатывал ни у одного — `rubric` вывел 8 из 8 построенных, `architecture`
упёрся в свои 3 и объявил это, `specs` потолка не имеет на этой метке.
- Кода не существует: ничего не запускалось, кроме `openspec validate --strict`.
Гейт на этой стадии не гоняется по построению.
- Команды сборки карты проекта в `Taskfile.yml` нет — архитектурный проход
собирал инвентарь понятий грепом, и такой инвентарь беднее подготовленного.
- `docs/adr/` и `docs/research/` прогоном не открывались: процессные документы.
Расхождение изменения с записанным решением этой стадией не ловится — это
работа сверки документации.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет:
проход независимой реализации упразднён решением о стоимости.
- Что будет с этим на живых данных, не проверял никто: данных нет, стадия —
стройка.