# ✨ Свести приём и чтение записей к одному контракту для приложения - **Тип:** feature - **Категория:** Очередь — Проект экранов и адресов согласован с владельцем 2026-08-15: на этом контракте стоят каркас приложения и все его экраны, а стоящие выше правки входа и хранилища его не подпирают - **Зачем:** Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. Экраны заводят задачу, видят её состояние и листают список — всё через один контракт. Обработчик `GET /api/status/:id` сегодня отвечает `404` на **любую** ошибку чтения, включая сбой базы, а `POST /api/audio` — `500` на любую ошибку заведения, включая негодный файл. Экран, построенный на таком контракте, показывает «не найдено» при упавшей базе. Отдельная ветка того же класса найдена ревью задачи `remove-telegram-intake` 2026-08-15, проход сверки требований: приём отвергает пустого владельца своей ошибкой, а транспорт переводит **любую** ошибку службы в `500`. Спека приёма при этом держит эту проверку ради понятного отказа — «отвечает отправителю понятным отказом до того, как запись попадёт в память». Сегодня путь недостижим: слой предъявления отвергает такого вызывающего раньше. Он становится достижимым вместе с личными токенами и экранами приложения, то есть ровно здесь. **Состав контракта владелец согласовал 2026-08-15**, разобрав его вместе с экранами приложения. Четыре развилки закрыты решением: - **Опрос `GET /api/status/:id` убирается целиком**, а с ним и имена его полей: `job_id` зовётся `id`, текст переезжает на свой адрес. Стадия проекта — стройка, данных на сервере нет, а внешней программы на прежнем контракте не существует: своего токена у неё не было, и держать два адреса на один вопрос не за чем. Ломка объявляется в спеке `intake`. - **Запись подписывается именем файла, данным отправителем.** Имя ложится **своей колонкой** — `original_filename`, — а колонка заголовка остаётся под название, которое дал человек либо посчитала языковая модель. Одной колонкой на оба смысла посчитанный заголовок затирал бы то, по чему человек узнаёт свою запись, и вернуть затёртое было бы неоткуда. Ответ несёт оба поля, а экран показывает заголовок и подставляет имя файла, пока заголовка нет. Норма `intake` сужается: имя не доходит до **имени файла** в хранилище и до журнала, а в самой записи его видит один владелец. Отсюда обязанность приёма: длину ограничить, управляющие знаки убрать. - **Аудио отдаётся своим адресом**, а не ссылкой хранилища с токеном файла: норма `storage` запрещает ссылке выходить в ответ отправителю. - **Текст едет отдельным адресом**, а не вместе с карточкой: шестичасовая расшифровка иначе задерживает показ шапки записи на мобильной сети. Адреса приложения после этого такие: | Метод и путь | Что отдаёт | | --- | --- | | `GET /app/me` | кто вошёл | | `GET /app/config` | чем сервер ограничивает: потолок размера записи, частота опроса, перечень известных расширений, потолок тем | | `POST /app/audiorecords` | список заведённых записей | | `GET /app/audiorecords` | страницу своих записей, новые сверху | | `GET /app/audiorecords/{id}` | карточку записи без текста | | `GET /app/audiorecords/{id}/text` | текст названного вида — сплошной либо репликами со временем | `GET /app/config` заводится здесь же, а не задачей экрана: пределы объявляет сервер, и экран, знающий их своей константой, расходится с ним молча — до первого отказа на гигабайтном файле, который человек уже успел отправить. Приём стоит тем же адресом, что и список, и отличается только методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя `audio` называло содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя запись он и создаёт. Элемент списка несёт идентификатор, заголовок, имя файла отправителя, краткое описание, темы, рубеж, признак остановки и её причину, длительность, размер и время заведения. Длительность с размером лежат сегодня строкой файла, а список по норме `storage` читается без содержимого — значит оба переезжают колонками записи и заполняются приёмом, который читает их у `ffprobe` и так. Адреса записи названы `audiorecords` — по центральной сущности сервиса, а не общим словом «записи»: последнее в хранилище значит строку любой коллекции. Коллекция при этом зовётся `audio_records`, и расхождение намеренное: в схеме имена в snake_case, у соседей по адресу — слитно. **Приложение целиком уезжает из `/api/` в своё пространство `/app/`** — решение владельца 2026-08-15. Пространство `/api/` принадлежит хранилищу: оно вешает туда двенадцать наборов своих адресов (`settings`, `collections`, `files`, `logs`, `realtime`, `batch`, `backups`, `crons`, `health`, `sql`, `oauth2-redirect` и собственные адреса входа коллекций), и поменять этот префикс нельзя — он литерал в `apis/base.go`, а не настройка. Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они молча. Прецедент в проекте уже принят — адреса входа вынесены на `/auth/*` этим же доводом (спека `access`). Цена переезда названа: правило неизвестного пути перечисляет теперь четыре корня — `/api/`, `/app/`, `/auth/` и `/_/` — вместо одного, а ограничитель частоты хранилища, настроенный на `/api/`, наших адресов больше не покрывает, и своё правило под `/app/` заводится здесь же. Настройки пользователя лягут на `/app/me/settings`; собственный `/api/settings` принадлежит хранилищу и остаётся ему. ## Затрагивает - `POST /app/audiorecords` — переезд приёма из `/api/audio`, коды ответа, форма ошибки и форма успешного ответа; - `GET /api/status/:id` — убирается вместе со своими именами полей; - `GET /app/me` — кто вошёл; - `GET /app/config` — пределы и свойства сервера для экранов; - `GET /app/audiorecords` — список своих записей с постраничным чтением и отбором по тому, в работе запись или нет; - `GET /app/audiorecords/{id}` и `GET /app/audiorecords/{id}/text` — карточка и текст названного вида, сплошной либо репликами со временем; - слой предъявления сессии и своё правило ограничителя частоты — оба переезжают с адресов на новый корень; - колонки `original_filename`, длительности и размера у аудиозаписи, и шаг схемы под них; - `internal/entity` и три места правки колонок в `internal/adapter/repo/pocketbase` — компилятор их расхождения не видит (инвариант «Колонки записи правятся в двух местах» в [CLAUDE.md](../../CLAUDE.md)); - приём: имя файла отправителя в свою колонку, с ограничением длины и уборкой управляющих знаков; - единая точка отображения доменной ошибки в код ответа — её сегодня нет ([conventions/errors.md](../../docs/conventions/errors.md)); - `internal/contract` — доменные ошибки под отображение, отбор списка по владельцу; - `internal/controller/http` целиком; - спека `intake` — новый адрес приёма, убранный опрос готовности и суженная норма про имя отправителя; - спека `access` — область слоя предъявления сессии названа там адресами приложения; - `docs/conventions/web-ui.md` — правило неизвестного пути перечисляет корни сервиса, а не один `/api/`; - `docs/architecture.md`, раздел «Единые точки проекта»; - `docs/database.md` — новые колонки записи. ## Критерии приёмки - Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при чтении даёт `500`, а не `404`, негодный файл — `400` с человекочитаемым текстом, а не `500`, а отказ приёма по пустому владельцу — `403`, как и отказ слоя предъявления. Тело ошибки при этом одной формы на всех адресах, без сырого `err.Error()` и без опечатки `transcibe`, а отображение живёт в одной функции, названной в `docs/architecture.md`. Оракул — три теста на код (репозиторий с ошибкой драйвера; файл, который отвергает разбор метаданных; вызов приёма без учётной записи), тест формы на четырёх ветвях отказа, пустой `grep -rn 'transcibe' internal/` и `task gate`, шаг `docs.py check`. - Список отдаётся страницами, новые сверху, и несёт заголовок, имя файла отправителя, длительность, размер, рубеж с признаком остановки и темы, не читая при этом ни расшифровки, ни структуры реплик. Оракул — два теста: выборка больше страницы; запись с расшифровкой, у которой чтение списка не трогает строку текста. - Ответ приёма отдаёт список заведённых записей и место под признак повторного файла, даже когда файл в запросе один, а имя файла отправителя лежит своей колонкой — обрезанное по пределу и без управляющих знаков — при пустом заголовке. Оракул — четыре теста приёма: тело ответа — список из одного элемента с полем признака повтора; имя `разговор.mp3` доходит до `original_filename`; колонка заголовка у принятой записи пуста; имя длиннее предела и с управляющими знаками доходит обрезанным и очищенным. - `GET /app/config` отдаёт потолок размера тем же числом, каким сервер отвергает запись сверх него, а не своей копией. Оракул — тест: значение в ответе равно `entity.MaxRecordSize`, и подмена константы меняет ответ. - Карточка записи и её текст читаются порознь, а прежний опрос готовности отвечает `404`: карточка несёт признак наличия текста, а текст отдаётся названным видом — сплошным либо репликами со временем. Оракул — четыре теста: карточка без поля текста; текст вида `transcript` сплошным; он же репликами со временем; прежние адреса `GET /api/status/{id}` и `POST /api/audio` на заведённой записи отвечают `404`. ## Рамки Аутентификацию и владельца не заводим — это `oidc-login` и `record-ownership`; задача про форму контракта. Публичный контракт после мерджа обратной правкой не откатывается, и по решению владельца от 2026-08-12 форма ответа приёма согласуется здесь один раз — сразу списком и с местом под признак повтора, — чтобы `dedup-by-content-hash`, `multi-file-upload` и `reject-oversized-recording` её не переписывали, а экран загрузки не переделывался под вторую форму. Приём по-прежнему берёт из запроса один файл: меняется форма ответа, не число файлов. Сюда же приехала опечатка `transcibe` в тексте ошибки приёма (нашёл проход `review-specs` на ревью change `2026-08-11-fix-http-handler-tests`): в одиночку текст ошибки менять нельзя, а здесь контракт переписывается целиком. Экранов не делаем ни одного — их берут `spa-skeleton` и задачи экранов. Три адреса контракт называет, но не реализует, потому что у каждого свой хозяин: поток аудио `GET /app/audiorecords/{id}/audio` — `play-recording-in-app`, чтение и запись настроек по адресу `/app/me/settings` — `settings-screen`, признак повторного файла — `dedup-by-content-hash`. Заголовок и темы, которые считает языковая модель, тоже не здесь: место под них в ответе есть, заполняет его `llm-insights-adapter`. Имени файла отправителя он при этом не трогает — колонки разные, и вопроса «что затрёт что» у них не возникает. Правки заголовка руками в приложении здесь тоже нет: колонка под неё заведена, а экран и адрес принесёт задача, которой это понадобится.