приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
## 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, состав
|
||||
адресов согласован там же.
|
||||
Reference in New Issue
Block a user