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

220 lines
18 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.
## Context
Сервис отвечает сегодня двумя адресами: приём и опрос готовности. Оба висят в
пространстве, которое принадлежит хранилищу, оба решают сами, каким кодом
ответить на отказ, и оба выросли из одного потребителя — внешней программы,
которой у сервиса сегодня нет.
Приложение строят следующие задачи очереди: каркас, экран загрузки, список,
действия над записью. Все четыре опираются на форму ответа, и переписывать её
на каждой значит переделывать экраны вслед за ней. Поэтому контракт согласуется
здесь **целиком и один раз** — включая места под то, что заполнят соседние
задачи: признак повторного файла, заголовок и темы от языковой модели, поток
аудио, настройки человека.
Стадия проекта — стройка: данных на сервере нет, сервис остановлен, выкладка
пойдёт с чистого листа. Совместимость с прежним контрактом поэтому не требуется,
и ломка объявляется прямо, а не переживается вторым адресом.
## Goals / Non-Goals
**Goals:**
- один контракт на приём и чтение, с кодом ответа по причине отказа;
- одно место, где доменная ошибка становится кодом и сообщением;
- собственное пространство адресов у приложения;
- список, читаемый без содержимого записи;
- пределы сервера, объявленные сервером.
**Non-Goals:**
- экранов не делаем ни одного — их берут `spa-skeleton` и задачи экранов;
- признак повторного файла контракт **называет** местом в ответе, но не
заполняет: хозяин — `dedup-by-content-hash`;
- поток аудио (`GET /app/audiorecords/{id}/audio`) и настройки человека
(`/app/me/settings`) контракт не нормирует вовсе — ни адресом, ни местом в
карточке. Их форму выбирают `play-recording-in-app` и `settings-screen`, и
карточку они дополнят своим полем. Обещание «назвать, но не реализовать»
снято: названным считается только то, что стоит требованием спеки;
- заголовок и темы, которые считает языковая модель, заполняет
`llm-insights-adapter`; здесь только место под них;
- правка заголовка руками — колонка заведена, адрес принесёт
`audiorecord-actions`;
- аутентификация и владелец не заводятся: это `oidc-login` и `record-ownership`,
оба сделаны.
## Decisions
### Новая capability `archive`, а не расширение `intake`
`intake` нормирует **приём** — что считается принятой записью и что с ней
происходит. Чтение своего архива — другое поведение с другим потребителем, и
сваленное в одну спеку оно размыло бы обе.
Отвергнуто: **сложить всё в `intake`** — спека выросла бы вдвое и перестала
отвечать на свой вопрос. Отвергнуто: **спека на каждый адрес** — дробление
раньше расхождения требований, прямо против правила именования capability.
Единая форма отказа и пределы сервера легли в `archive`, а не в `intake`,
потому что они свойство **всей** поверхности приложения, а не приёма: у нормы,
живущей в двух домах, дома нет.
### Отображение доменной ошибки — одна функция, названная в архитектуре
Сегодня такой точки нет, и это записано расхождением в
[conventions/errors.md](../../../docs/conventions/errors.md). Заводим её в слое
транспорта: доменная ошибка на входе, код и человекочитаемое сообщение на
выходе. Таблица соответствий — та, что уже стоит в конвенции; новая ветвь
заводится sentinel'ом и добавляется туда же.
Отвергнуто: **каждый обработчик решает сам** — это сегодняшнее состояние, и
именно оно даёт `404` на упавшую базу. Отвергнуто: **отображение в доменном
слое** — код HTTP там не к месту, а второй транспорт (если появится) получил бы
чужие коды.
Ошибок домена не хватает на все ветви: приём отвечает `400` на негодную запись,
а сегодня ошибка источника метаданных доезжает голой. Заводим ей свой признак —
иначе ветвь по умолчанию отдаст `500`.
### Переезд в `/app/`, а слой сессии — на корень
Слой предъявления сессии вешается на группу корня приложения, а не на перечень
адресов: перечень рос бы с каждым новым адресом, и забытый в нём адрес молча
перестал бы принимать куку.
Своё правило ограничителя частоты заводится под тот же корень: правило
хранилища настроено на его собственный корень и наших адресов больше не
покрывает. Цена названа прямо, потому что это тихая потеря — без правила
ограничителя нет вовсе, и заметить это нечем.
### Три новых колонки записи и один шаг схемы
`original_filename`, длительность и размер ложатся колонками записи. Длительность
и размер лежат сегодня строкой файла, а список по норме `storage` читается без
содержимого — доставать их строкой файла значит читать по строке на каждую
запись списка.
Шаг схемы **один на все три**: применённый шаг не переписывается, и три шага
вместо одного стоили бы трёх необратимых решений там, где хватает одного.
Колонки правятся в двух местах пакета хранилища плюс шаг схемы — инвариант
проекта, компилятор их расхождения не видит. Имя файла и длительность с размером
при этом кладёт **приём**, а не конвейер: в `applyOwnedByPipeline` они не
попадают, иначе снимок шага стёр бы их.
Отвергнуто: **держать имя файла в колонке заголовка** — посчитанный заголовок
затирал бы то, по чему человек узнаёт свою запись.
Отвергнуто на чекпоинте 2026-08-15: **брать длительность и размер у строки файла
батчем на страницу**. Довод против колонок был в том, что обе величины уже лежат
у файла и станут копиями, которые некому держать равными; решением владельца
колонки остаются, а равенство объявлено ненужным прямо — на записи лежит снимок
принятого, на файле величины сегодняшней копии, и это разные вопросы. Норма
записана спекой `storage`, иначе два числа читались бы как копии одного.
### Страница задаётся ключом, а не номером
Решение владельца на чекпоинте 2026-08-15. Приём пишет в голову той же ленты,
которую читает список, и номер страницы сдвигал бы окно при первой же записи,
заведённой между двумя запросами: один элемент пришёл бы дважды, другой не пришёл
бы никогда, и оба раза молча.
Ключ непрозрачен и задаёт положение полным ключом сортировки — парой «время
заведения и идентификатор»: у записей одного запроса время совпадает, и порядок
между ними иначе не определён.
Отвергнуто: **номер страницы** — форма совпала бы с той, какой отдаёт страницы
хранилище, и позволила бы экран с нумерацией; цена — молчаливая потеря записи из
выдачи. Экран с нумерацией страниц архиву не нужен: листают его «дальше».
### Имя отправителя: предел длины и уборка управляющих знаков
Имя приходит извне и приёму не подконтрольно. Предел длины назначает сервис;
управляющие знаки убираются прежде сохранения, иначе они доедут до экрана и до
панели владельца. Инвариант приватности при этом не трогается: имя по-прежнему не
идёт ни в имя файла в хранилище, ни в журнал.
### Темы в списке — названиями, а не ссылками
Ничего сегодня темы не пишет: их посчитает `llm-insights-adapter`. Поле в ответе
всё равно заполняется названиями, а не идентификаторами: отдай мы ссылки, экран
не смог бы их показать, и форма ответа переделывалась бы задачей языковой модели —
ровно то, ради чего контракт согласуется здесь.
### Текст отдаётся по названному виду — одним закрытым перечнем
Видов три: `transcript`, `literary`, `replicas`. Перечень назван так, а не парой
«вид текста плюс форма показа», потому что реплики со временем — не вид текста:
они лежат структурой разбора и принадлежат записи. Пара из двух параметров
обещала бы сочетания, которых не существует.
Вычитанный текст назван вперёд, хотя считает его отдельная задача: перечень без
него пришлось бы расширять правкой публичного контракта — того самого, который
согласуется здесь один раз.
Отвергнуто: **«сплошной либо репликами»** — так стояло в постановке, и так
вычитанный текст недостижим вовсе. Отвергнуто: **два параметра** — половина их
сочетаний пуста.
### Отказ несёт машиночитаемый код, а не только фразу
Тело отказа несёт два поля: код из закрытого перечня и сообщение человеку. Кода
HTTP не хватает — «файл негоден», «поля записи нет» и «неизвестный вид» все три
`400`, а приложению надо решать, предлагать ли повтор.
Отвергнуто: **одна фраза** — приложению осталось бы разбирать русский текст, и
первая же задача экрана добавила бы поле кода, то есть переписала бы контракт.
### Имена полей названы спекой, а не выбраны кодом
Контракт согласуется один раз ради того, чтобы экраны не переделывались. Имена
полей — часть контракта наравне с адресами: выбранные кодом, они станут
известны экранам, и переименование после этого стоит правки приложения. Форма
страницы — `items`, `next_cursor`, `total_items`: собственные адреса хранилища
приложению закрыты, и совпадать с его формой страницы не с чем.
### Отбор списка различает три состояния, а не два
Запись в работе, остановленная, прошедшая конвейер. Надвое не делится:
остановленная не в работе и не завершена, и при отборе надвое выпала бы из обеих
половин — исчезла бы из списка при любом значении, хотя ради неё список и
открывают.
Предикат выводится из дескриптора рубежа, а не перечисляет рубежи строкой
запроса: отбор списка — очередной потребитель словаря рубежей, и инвариант
проекта запрещает перечислять их порознь. Правило `internal/archrules`
дописывается на нового потребителя здесь же, иначе инвариант держится памятью.
### Отказ по превышению размера получает свою ветвь
Потолок размера применяется уже сегодня, а ответ на его срабатывание не
нормирован ничем и уходит телом ограничителя тела — мимо единой формы. Это самый
частый отказ у человека на мобильной сети, и читается он сейчас как «сломался
сервер». Заводится своя доменная ветвь, код `413`, предел в теле числом.
## Risks / Trade-offs
- **Ломка публичного контракта необратима.** → Стадия — стройка, данных на
сервере нет, внешней программы на прежнем контракте не существует. Прежние
адреса отвечают `404`, а не отсутствуют молча.
- **Шаг схемы применён — не переписать.** → Три колонки заводятся одним шагом, и
состав их сверен со спекой `storage` до написания кода.
- **Правило ограничителя частоты можно забыть завести.** → Проверяется тестом:
настройки несут правило, чей адрес начинается корнем приложения.
- **Отбор списка по владельцу — место, где утечка стоит дороже всего.** →
Сужение владельцем лежит в репозитории, а не в обработчике, и пустой владелец
не совпадает ни с одной записью — норма `access` уже это держит.
- **Место под признак повтора и под темы заполняется не здесь.** → Форма
зафиксирована спекой, и соседние задачи её не переписывают, а заполняют.
## Migration Plan
Переносить нечего: на сервере данных нет, выкладка идёт с чистого листа. Шаг
схемы применяется на пустой базе. Откат — обратной правкой контракта, и она
запрещена решением владельца: контракт после мерджа не откатывается.
## Open Questions
Нет: четыре развилки контракта закрыты решением владельца 2026-08-15, состав
адресов согласован там же.