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

18 KiB
Raw Blame History

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. Заводим её в слое транспорта: доменная ошибка на входе, код и человекочитаемое сообщение на выходе. Таблица соответствий — та, что уже стоит в конвенции; новая ветвь заводится 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, состав адресов согласован там же.