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

- адреса приложения переехали в своё пространство `/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,2 @@
schema: spec-driven
created: 2026-08-15
@@ -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, состав
адресов согласован там же.
@@ -0,0 +1,70 @@
## Why
Приложение строить не на чем. Сегодня сервис отвечает «записи нет» на упавшую
базу и «внутренняя ошибка» на негодный файл: код ответа называет место, где
отказ случился, а не его причину. Экран, собранный на таком контракте, показывает
человеку «не найдено», когда на самом деле лежит хранилище.
Списка своих записей у сервиса нет вовсе, карточка и текст едут одним ответом, а
пределы, которыми сервис ограничивает загрузку, приложению неоткуда узнать —
кроме как повторить их своей константой и разойтись с сервером молча.
## What Changes
- **BREAKING.** Опрос готовности убирается целиком вместе со своими именами
полей. Стадия проекта — стройка, на сервере данных нет, а внешней программы на
прежнем контракте не существует: своего токена у неё не было.
- **BREAKING.** Приложение уезжает из общего пространства `/api/` в своё `/app/`.
Общее пространство принадлежит хранилищу, и обновление библиотеки вправе занять
там имя рядом с нашим.
- **BREAKING.** Приём стоит тем же адресом, что и список, и отличается методом:
он заводит аудиозапись, а не кладёт файл. Ответ приёма отдаёт список заведённых
записей и место под признак повторного файла — форма согласуется один раз,
чтобы соседние задачи её не переписывали.
- Отказ отвечает своей причиной: сбой хранилища виден как сбой, негодная запись —
как негодная, отказ по чужому имени — как отказ. Тело отказа одной формы на
всех адресах приложения, и собирает его одно место.
- Появляется чтение своего архива: страница записей новыми сверху, карточка
записи без текста и текст названного вида — сплошной либо репликами со
временем. Шестичасовая расшифровка больше не задерживает показ шапки.
- Появляется адрес, которым сервис объявляет свои пределы: потолок размера
записи, частота опроса, перечень известных расширений, потолок тем.
- Запись подписывается именем файла, данным отправителем: имя ложится своей
колонкой и не спорит с заголовком, который дал человек либо посчитала языковая
модель. Длина ограничена, управляющие знаки убраны.
- Длительность и размер переезжают колонками записи: список читается без
содержимого, а обе величины приём узнаёт у источника метаданных и так.
## Capabilities
### New Capabilities
- `archive`: архив своих записей глазами приложения — пространство адресов
приложения и единая форма отказа, пределы, которыми сервис ограничивает
загрузку, и чтение своего архива: страница записей, карточка и текст названного
вида.
### Modified Capabilities
- `intake`: приём переезжает на новый адрес и меняет форму ответа на список;
опрос готовности убирается целиком; имя, данное отправителем, доходит до самой
записи отдельной колонкой, оставаясь за пределами имени файла в хранилище и
журнала.
- `access`: область слоя предъявления сессии названа адресами приложения, и
среди них появляется вопрос «кто вошёл».
- `storage`: у записи появляются колонки имени файла отправителя, длительности и
размера.
- `pipeline`: исход своей записи владелец узнаёт карточкой записи, а не убранным
опросом готовности; два требования называли держателем нормы адрес, которого
больше нет.
## Impact
- Приём и чтение записей по HTTP: все адреса приложения, коды ответа и форма
тела.
- Схема хранилища: новый шаг под три колонки записи.
- Слой предъявления сессии и своё правило ограничителя частоты переезжают на
новый корень.
- Правило неизвестного пути в приложении перечисляет корни сервиса, а не один.
- Внешней программе на прежнем контракте ломается всё; такой программы у сервиса
сегодня нет.
@@ -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/` прогоном не открывались: процессные документы.
Расхождение изменения с записанным решением этой стадией не ловится — это
работа сверки документации.
- Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет:
проход независимой реализации упразднён решением о стоимости.
- Что будет с этим на живых данных, не проверял никто: данных нет, стадия —
стройка.
@@ -0,0 +1,141 @@
## MODIFIED Requirements
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
живут под его собственным корнем. Собственная поверхность хранилища под него не
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает карточку этой записи своей сессией
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её карточку спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится предъявитель сессии
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
## ADDED Requirements
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
разметку само и вошедшего узнаёт ответом.
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
может вовсе — этот адрес единственный способ его узнать.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Вошедший узнан
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Сессии нет
- **WHEN** приложение спрашивает, кто вошёл, без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** адреса почты в ответе нет
@@ -0,0 +1,466 @@
## ADDED Requirements
### Requirement: Адреса приложения живут своим пространством
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно
вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он
литерал библиотеки, а не настройка.
Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся:
обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они
молча — тем же адресом начнёт отвечать не тот обработчик.
Цена переезда называется здесь же. Правило неизвестного пути, по которому
приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не
один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель
частоты хранилища настроен на чужой корень и наших адресов больше не покрывает,
поэтому сервис MUST заводить своё правило под корень приложения.
Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность
и выключен умолчанием, поэтому включение нашего правила вводит в действие и его
собственные — на входе, на заведении записей и на его адресах. Принимается
сознательно: без включения наше правило не значит ничего.
Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки
человека живут под корнем приложения.
#### Scenario: Адрес приложения отвечает под своим корнем
- **GIVEN** человек вошёл и предъявил сессию
- **WHEN** он спрашивает список своих записей под корнем приложения
- **THEN** ответ приходит от сервиса, а не от хранилища
#### Scenario: Прежние адреса приложения не отвечают
- **GIVEN** заведена запись
- **WHEN** её спрашивают прежними адресами в чужом пространстве
- **THEN** ответ имеет код `404`
#### Scenario: Ограничитель частоты покрывает адреса приложения
- **WHEN** сервис поднялся
- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем
приложения
### Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- отсутствие сессии — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
адресах приложения две, а самый частый отказ у человека на мобильной сети —
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
числом.
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
#### Scenario: Негодная запись видна как негодная
- **GIVEN** источник метаданных не может прочитать присланную запись
- **WHEN** отправитель шлёт её приёмом
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают без сессии, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
### Requirement: Сервис объявляет свои пределы
Сервис SHALL отдавать свои пределы отдельным адресом — `GET /app/config` — и
MUST называть в нём потолок размера одной записи, потолок размера страницы,
частоту опроса карточки, перечень известных расширений и потолок числа тем у
записи.
Предел зовётся частотой опроса **карточки**, а не готовности: адрес опроса
готовности это же изменение убирает целиком, и читатель через месяц искал бы то,
чего нет.
Имена полей ответа нормативны: `max_record_size_bytes`, `max_page_size`,
`poll_interval_ms`, `known_extensions`, `max_topics_per_record`.
**Каждый объявленный предел MUST быть тем же значением, которое сервис
применяет, а не его копией.** Правило общее, а не про один потолок размера:
приложение, знающее предел своей константой, расходится с сервером молча — до
первого отказа на записи, которую человек уже успел отправить по мобильной сети.
Ровно то же случается, когда предел объявлен сервером, но взят из второй
константы рядом с применяемой.
Отсюда источник у каждого:
- потолок размера записи — то число, которым сервис ограничивает тело запроса
приёма и отвергает запись кодом `413`;
- потолок числа тем у записи — то число, которым его ограничивает схема
хранилища; норму держит capability `storage`;
- потолок размера страницы — то число, до которого сервис усекает запрошенный
размер страницы;
- частота опроса — выводится из **доли** бюджета ограничителя частоты под корнем
приложения и MUST не задаваться своей константой. Доля, а не весь бюджет:
опрос идёт не один — в ту же секунду приложение листает список, открывает
соседнюю карточку и грузит новую запись, а бюджет один на все адреса
приложения и считается по адресу спрашивающего, а не по учётной записи.
Объявленная частота, равная всему бюджету, отдавала бы отказ на любом втором
запросе — тот самый, которого объявление обещает избежать. Иначе приложение,
честно опрашивающее карточку с объявленной частотой, упирается в собственный
ограничитель сервиса — и получает отказ, которого сервис сам же ему и обещал
избежать;
- перечень известных расширений — тот же, что сужает метку метрики, за вычетом
собственного умолчания сервиса `audio`: оно не формат, и подсказкой человеку
выходить не должно. Второй перечень рядом с первым разошёлся бы с ним молча.
**Перечень известных расширений — исключение в другом: сервис по нему не
судит.** Приём о годности записи не судит сам — расширение он берёт из
имени файла, а пригодность содержимого узнаёт у источника метаданных, — и
перечень служит приложению подсказкой для диалога выбора файла, не более.
Умолчать об этом нельзя: приложение прочитало бы перечень как «что можно
загружать» и отвергало бы запись, которую сервис принял бы и расшифровал.
Адрес MUST быть доступен тому же, кому доступны прочие адреса приложения:
пределы не тайна, но отдельного открытого адреса ради них не заводится.
#### Scenario: Потолок размера равен тому, которым сервис отвергает
- **GIVEN** человек вошёл и предъявил сессию
- **WHEN** он спрашивает пределы сервиса
- **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает
тело запроса приёма
#### Scenario: Потолок страницы равен применяемому
- **WHEN** человек спрашивает пределы сервиса, а затем просит страницу размером
сверх объявленного потолка
- **THEN** размер отданной страницы не превышает объявленного потолка
### Requirement: Страница своих записей
Сервис SHALL отдавать владельцу страницу его записей — `GET /app/audiorecords`
новыми сверху, и MUST не показывать в ней ни одной чужой записи. Спрашивающий с
пустым именем MUST не получать ни одной записи.
Ответ MUST нести страницу, ключ следующей страницы и общее число записей. Число
записей одного человека растёт годами — сервис объявлен архивом, — и ответ без
страниц перестал бы помещаться в память телефона.
**Страница задаётся ключом, а не номером.** Приём пишет в голову той же ленты,
которую читает список, и человек, загрузивший запись и листающий свой архив, —
штатный сценарий, а не редкость. Номер страницы сдвинул бы окно на единицу:
последний элемент первой страницы пришёл бы вторым разом первым элементом второй,
а один элемент между ними не пришёл бы никогда. Отказ молчаливый — ни кода, ни
строки в журнале, — и человек видел бы архив, в котором записи нет.
Ключ MUST быть непрозрачным для спрашивающего и MUST задавать положение
**полным** ключом сортировки — парой «время заведения и идентификатор». Одного
времени мало: у записей, принятых одним запросом, оно совпадает, и порядок между
ними иначе не определён вовсе.
Ключа следующей страницы нет — страница последняя; пустая страница MUST отвечать
успехом, а не отказом: отсутствие записей не есть ошибка.
Ключ, который сервис не может прочитать — протухший, обрезанный, подделанный, —
MUST давать отказ по негодному вводу. Молчаливая отдача первой страницы вместо
этого дала бы человеку архив, листающийся по кругу, и ни строки в журнале.
**Сторона запроса нормируется наравне со стороной ответа.** У размера страницы
MUST быть умолчание и потолок; размер сверх потолка MUST усекаться до него, а не
отвергаться, а негодное значение — ноль, отрицательное, нечисловое — MUST давать
отказ по негодному вводу. Незаданный потолок был бы способом попросить весь архив
одним запросом, то есть обойти постраничность тем самым параметром, ради которого
она заведена.
Имена полей ответа и элемента нормативны: экраны строятся на них, и
переименование после того, как экран написан, стоит правки приложения.
- параметры запроса: `cursor`, `limit`, `filter`;
- страница: `items`, `next_cursor`, `total_items`;
- элемент: `id`, `title`, `original_filename`, `brief`, `topics`, `state`,
`halted`, `halt_reason`, `duration_ms`, `size_bytes`, `created_at`.
Значение `state` MUST принадлежать перечню рубежей конвейера, а `halt_reason`
перечню причин остановки. Оба перечня объявлены одним местом, и перечислять их
порознь в потребителе нельзя: рубеж, добавленный конвейером, иначе разошёлся бы
с ответом молча.
Чтение страницы MUST не читать ни расшифровки, ни структуры реплик: обе лежат
порознь от записи ровно затем, чтобы список их не тянул. Длительность и размер
MUST браться колонками самой записи, а не строкой её файла.
Машинный текст отказа MUST в элемент страницы не попадать: он принадлежит
журналу владельца сервиса. Причина остановки — значение из закрытого перечня, и
она не он.
Значения причины остановки этим требованием впервые выходят в публичный ответ, и
это осознанно: без причины признак остановки не говорит человеку, чего ждать —
повтора, своего действия или ничего. Превращает значение в русскую фразу
**приложение**, а не сервис: сервис отдаёт значение перечня, и второй словарь
фраз на стороне сервера разошёлся бы с тем, что показывает экран.
**Отбор MUST различать три состояния, а не два:** запись в работе (`working`),
запись остановлена (`halted`), запись прошла конвейер (`done`). Незаданный отбор
значит «все». Двух значений не хватает: остановленная
запись не в работе и не завершена, и при отборе надвое она выпала бы из обеих
половин — то есть исчезла бы из списка при любом значении отбора, хотя ради неё
человек список и открывает. Предикат каждого состояния MUST выводиться из
дескриптора рубежа и признака остановки, а не перечислять рубежи строкой запроса:
рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх.
#### Scenario: Страница отдаётся новыми сверху
- **GIVEN** владелец завёл записей больше, чем помещается на страницу
- **WHEN** он спрашивает первую страницу
- **THEN** в ней лежит ровно столько записей, сколько вмещает страница
- **AND** первой стоит заведённая последней
- **AND** ответ несёт общее число его записей и ключ следующей страницы
#### Scenario: Запись, заведённая между страницами, окна не сдвигает
- **GIVEN** владелец прочитал первую страницу и взял ключ следующей
- **WHEN** он заводит новую запись и спрашивает следующую страницу этим ключом
- **THEN** ни один элемент первой страницы в ней не повторяется
- **AND** ни одна запись между страницами не пропущена
#### Scenario: Записи с одним временем заведения идут в устойчивом порядке
- **GIVEN** две записи заведены одним запросом и время заведения у них совпадает
- **WHEN** владелец читает страницу дважды
- **THEN** порядок этих записей в обоих ответах один и тот же
#### Scenario: Последняя страница
- **WHEN** владелец дочитал архив до конца
- **THEN** ответ имеет код `200`, а ключа следующей страницы в нём нет
#### Scenario: Чужих записей в странице нет
- **GIVEN** записи заведены двумя вошедшими
- **WHEN** страницу спрашивает один из них
- **THEN** в ней лежат только его записи
#### Scenario: Список не тянет расшифровку
- **GIVEN** у записи есть расшифровка
- **WHEN** владелец спрашивает страницу своих записей
- **THEN** текста расшифровки в ответе нет
- **AND** чтение страницы строку текста не трогает
#### Scenario: Размер страницы сверх потолка усекается
- **WHEN** владелец просит страницу размером больше объявленного потолка
- **THEN** ответ имеет код `200`, а размер страницы равен потолку
#### Scenario: Негодный размер страницы отвергается
- **WHEN** владелец просит страницу размером ноль либо нечисловым значением
- **THEN** ответ имеет код `400`
#### Scenario: Остановленная запись видна отбором
- **GIVEN** у владельца есть запись в работе, остановленная запись и прошедшая
конвейер
- **WHEN** он спрашивает каждое из трёх состояний отбором
- **THEN** каждая запись приходит ровно в одном из них
- **AND** остановленная приходит с признаком остановки и её причиной
### Requirement: Карточка записи отдаётся без текста
Сервис SHALL отдавать владельцу карточку одной записи — `GET
/app/audiorecords/{id}` — и MUST не класть в неё текста расшифровки.
**Карточка несёт те же поля, что и элемент страницы, плюс перечень доступных
видов текста** — полем `available_views`. Две формы одной вещи разошлись бы
молча, поэтому состав задан одной нормой, а не двумя.
Отсюда обязанность, которой держится инвариант проекта «принятая запись не
теряется молча»: карточка MUST нести рубеж, признак остановки и её причину.
Прежде исход своей записи владелец узнавал опросом готовности; опрос убран, и
единственным местом, где отправитель узнаёт о неудаче, становится карточка. Норма
эта переехала сюда целиком — capability `pipeline` называет держателем её этот
адрес.
Вид считается доступным по **содержимому**, а не по наличию ссылки на текст.
Ссылка без содержимого — состояние штатное: пустой ответ распознавания сервис
признаёт нормой и записывает его в журнал. Строй мы перечень по ссылкам,
карточка объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов»
вечно — приложение опрашивало бы его без конца, а человек видел бы завершённую
запись, из которой текст «вот-вот появится».
Перечень MUST присутствовать в ответе **всегда**, в том числе пустым: отсутствие
поля и пустой перечень приложение не различит, а значат они разное.
Перечень доступных видов MUST быть **перечнем**, а не признаком «текст есть».
Видов больше одного, и шаг завершения пишет их несколькими операциями: состояние
«сплошной текст есть, реплик ещё нет» достижимо. Один признак на несколько видов
отправил бы приложение за репликами, которых нет, — и исход стал бы функцией
того, в каком месте прервался шаг, а не состояния записи. Пустой перечень значит
«текста ещё нет».
Шестичасовая расшифровка, приехавшая вместе с шапкой записи, задерживает показ
на мобильной сети на то время, которое человеку не нужно ждать: шапку он читает
сразу, а текст — если решил читать.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор.
#### Scenario: Карточка без текста
- **GIVEN** у записи есть расшифровка
- **WHEN** владелец спрашивает её карточку
- **THEN** поля с текстом в ответе нет
- **AND** перечень доступных видов несёт сырую расшифровку
#### Scenario: Остановленная запись видна карточкой
- **GIVEN** запись остановлена признаком по исчерпании отказов
- **WHEN** владелец спрашивает её карточку
- **THEN** карточка несёт достигнутый рубеж, признак остановки и её причину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Текста ещё нет
- **GIVEN** запись не дошла до расшифровки
- **WHEN** владелец спрашивает её карточку
- **THEN** перечень доступных видов пуст
#### Scenario: Чужая карточка неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её карточку спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
### Requirement: Текст записи отдаётся названным видом
Сервис SHALL отдавать текст записи отдельным адресом — `GET
/app/audiorecords/{id}/text` — и MUST отдавать **вид, названный спрашивающим**.
Отдача «последнего записанного» сделала бы ответ функцией порядка записи, а не
состояния записи.
**Перечень видов закрыт, и каждое его значение называет ровно одну хранимую
вещь:**
- `transcript` — сырая расшифровка сплошным текстом;
- `literary` — вычитанный текст сплошным;
- `replicas` — реплики со временем.
Перечень назван так, а не парой «вид текста плюс форма показа», потому что
реплики со временем — не вид текста: они лежат структурой разбора и принадлежат
записи, а не тексту. Пара из двух параметров обещала бы сочетания, которых не
существует.
Вычитанный текст назван здесь, хотя считает его отдельная задача: перечень,
заведённый без него, пришлось бы расширять правкой публичного контракта — того
самого, который согласуется здесь один раз. До появления вычитанного текста
значение просто не встречается в перечне доступных видов у карточки.
Значения `transcript` и `literary` MUST совпадать с видами текста, объявленными
хранилищем: два словаря об одном разошлись бы молча.
Текста запрошенного вида нет — сервис MUST отвечать кодом `409`, а не пустой
строкой и не `404`. Пустая строка читается как «расшифровка пуста»; `404` слился
бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на
своей записи, загруженной минуту назад, — ровно тот отказ, ради устранения
которого заводится весь контракт.
Вид, которого сервис не знает, и незаданный вид MUST давать отказ по негодному
вводу: умолчание сделало бы ответ функцией того, что успел записать конвейер.
Текст чужой записи MUST быть недоступен наравне с её карточкой.
#### Scenario: Сырая расшифровка сплошным текстом
- **GIVEN** у записи есть сырая расшифровка
- **WHEN** владелец спрашивает её текст видом `transcript`
- **THEN** ответ несёт содержимое сырой расшифровки
#### Scenario: Реплики со временем
- **GIVEN** у записи есть структура реплик
- **WHEN** владелец спрашивает её текст видом `replicas`
- **THEN** ответ несёт реплики, и у каждой стоит её время
#### Scenario: Текста этого вида ещё нет
- **GIVEN** у записи есть сырая расшифровка и нет структуры реплик
- **WHEN** владелец спрашивает её текст видом `replicas`
- **THEN** ответ имеет код `409`
- **AND** он отличается от ответа на неизвестный идентификатор
#### Scenario: Вид неизвестен или не назван
- **WHEN** владелец спрашивает текст видом, которого сервис не знает, либо не
называет вида вовсе
- **THEN** ответ имеет код `400`
@@ -0,0 +1,280 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
меняется форма ответа, не число файлов.
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
стать не может.
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Имя файла в хранилище
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и
содержимым своим приёму не подконтрольно.
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя
подписывает запись».
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
принятую запись. Ответ MUST иметь код `400`: причина отказа — присланная запись,
а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит
отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит
журналу, а не отправителю.
Отображение этой ошибки в код и сообщение живёт одним местом на все адреса
приложения; норму держит capability `archive`.
#### Scenario: Источник метаданных вернул ошибку
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /app/audiorecords` с этой записью
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** аудиозаписи не заводится
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит
один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные
логи.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
сценарии судят именно его.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
несёт опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
несёт опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
## ADDED Requirements
### Requirement: Имя файла отправителя подписывает запись
Приём SHALL класть имя файла, данное отправителем, в собственную колонку
аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек
узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт
название, которое дал человек либо посчитала языковая модель, и одной колонкой на
оба смысла посчитанное название затирало бы то, по чему запись узнают, — а
вернуть затёртое было бы неоткуда.
Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не
сочиняет.
Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём
MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем
сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель.
Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет.
#### Scenario: Имя доходит до записи
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3`
#### Scenario: Заголовок принятой записи пуст
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка заголовка у заведённой записи пуста
#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет
## REMOVED Requirements
### Requirement: Опрос готовности задачи
**Reason**: Адрес опроса отвечал сразу на три вопроса — рубеж записи, время её
заведения и текст расшифровки, — и держать два адреса на один вопрос не за чем.
Карточка записи и её текст читаются теперь порознь: шестичасовая расшифровка
иначе задерживает показ шапки записи на мобильной сети. Вместе с адресом уходят
имена его полей: `job_id` зовётся `id`.
**Migration**: Рубеж, время заведения и признак остановки берутся карточкой
записи — `GET /app/audiorecords/{id}`, — а текст расшифровки отдельным адресом
`GET /app/audiorecords/{id}/text`. Оба нормирует capability `archive`.
Переносить нечего: стадия проекта — стройка, данных на сервере нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
@@ -0,0 +1,95 @@
## MODIFIED Requirements
### Requirement: Число отказов ограничивает повторы шага
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
никогда.
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
объявления исхода, отказ тратит.
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Остановка эта видна владельцу записи **карточкой записи** наравне с
прочими — норму держит capability `archive`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
запись.
#### Scenario: Запись отказывает на каждой попытке
- **GIVEN** шаг конвейера отказывает на каждой попытке
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** карточка записи отдаёт владельцу признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
- **WHEN** запись захватывается снова заданное число раз
- **THEN** у неё появляется признак остановки
#### Scenario: Остановка сервиса отказа не тратит
- **GIVEN** шаг работает над записью
- **WHEN** сервис останавливают, и шаг прерывается отменой
- **THEN** число отказов записи прежнее
- **AND** признака остановки у записи не появляется
#### Scenario: Прошедшая запись отказов не копит
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
- **WHEN** смотрят её число отказов
- **THEN** оно не приблизилось к пределу
### Requirement: Конвейер ответа отправителю не шлёт
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса;
адрес карточки и содержимое ответа нормирует capability `archive`.
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
она исполняется.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
записи — там остановка видна признаком и причиной — и журналом владельца, где у
неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся отдельным адресом текста записи
#### Scenario: Остановка видна карточкой, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её карточку
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
@@ -0,0 +1,104 @@
## MODIFIED Requirements
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
расшифровка лежит колонкой той же строки и читается при каждом захвате.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
Тем же доводом запись MUST нести своими колонками **имя файла, данное
отправителем, длительность и размер**. Все три показываются в списке. Приём
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
вместе с записью.
**Имена колонок и единицы измерения нормативны:** `original_filename`,
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
ноль. Решение владельца 2026-08-15.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
записи он не принадлежит.
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
первую.
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
#### Scenario: Длительность и размер читаются без строки файла
- **GIVEN** запись принята
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Тексты и структура лежат отдельно от записи
Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом
текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не
хранить их колонками.
Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на
каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится
за каждую такую колонку отдельно.
**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре
«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый
ответ несколькими операциями и только потом двигает рубеж: прерванный на середине
и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит
приложению, называет вид запросом — норму держит capability `archive`.
#### Scenario: Расшифровка лежит своей строкой
- **GIVEN** запись прошла распознавание
- **WHEN** смотрят, где лежит текст расшифровки
- **THEN** он лежит отдельной строкой, на которую запись ссылается
#### Scenario: Повтор шага не заводит второй расшифровки
- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа
- **WHEN** шаг повторяется с прежнего рубежа
- **THEN** строка расшифровки у записи одна
@@ -0,0 +1,189 @@
## 1. Схема и модель записи
- [x] 1.1 Завести шаг схемы под три колонки записи: `original_filename`,
`duration_ms`, `size_bytes`. Один шаг на все три; применённый шаг не
переписывается. Единица стоит в имени колонки
- [x] 1.2 Добавить три поля в `entity.AudioRecord` с комментарием о разрезе
«имя файла против заголовка»; неизвестную длительность отличить от нулевой
- [x] 1.3 Провести колонки через оба места правки в пакете хранилища:
`applyToRecord` кладёт их (это заведение), `applyOwnedByPipeline` — не трогает
(конвейер их не меняет), `recordToAudioRecord` читает
- [x] 1.4 Добавить темы записи в модель и в чтение записи; в запись их не кладёт
ни приём, ни конвейер
- [x] 1.5 Прогнать `schema_test.go` и сверку правил `internal/archrules`
## 2. Приём: имя файла, длительность и размер
- [x] 2.1 Завести предел длины имени отправителя и уборку управляющих знаков
одним местом в домене; предел назвать константой с обоснованием
- [x] 2.2 Записать в принимаемую запись очищенное имя, длительность и размер;
колонку заголовка оставить пустой
- [x] 2.3 Проверить тестом, что имя отправителя не появилось ни в имени файла
хранилища, ни в журнале — прежние тесты приватности остаются зелёными
## 3. Отображение доменной ошибки в ответ
- [x] 3.1 Завести доменный признак «присланная запись негодна» и обернуть им
отказ источника метаданных в службе приёма
- [x] 3.2 Написать единую функцию отображения доменной ошибки в код ответа и
человекочитаемое сообщение по таблице из `docs/conventions/errors.md`
- [x] 3.3 Завести единую форму тела отказа и провести через неё все ветви:
ненайденная запись, негодный ввод, отсутствие учётной записи, сбой хранилища
- [x] 3.4 Завести доменный признак «запись сверх потолка размера», код `413`,
предел в теле числом
- [x] 3.5 Убрать опечатку `transcibe` из текста ошибки приёма
- [x] 3.6 Завести закрытый перечень машиночитаемых кодов отказа и отдавать их
вторым полем тела рядом с сообщением человеку
- [x] 3.7 Назвать точку отображения в `docs/architecture.md`, «Единые точки
проекта», и снять расхождение в `docs/conventions/errors.md`
- [x] 3.8 Дописать в таблицу `docs/conventions/errors.md` ветви, которых там нет:
`401`, `403`, `413` и машиночитаемый код отказа — объявленный источник единой
точки иначе расходится с ней в первый же день
## 4. Пространство адресов приложения
- [x] 4.1 Перевесить группу маршрутов с чужого корня на `/app/`, слой сессии
повесить на группу корня, а не на перечень адресов
- [x] 4.2 Завести своё правило ограничителя частоты под корень приложения
- [x] 4.3 Убрать прежние адреса `POST /api/audio` и `GET /api/status/{id}`
целиком
## 5. Адреса чтения
- [x] 5.1 `GET /app/me` — идентификатор учётной записи и имя к показу, без
адреса почты
- [x] 5.2 `GET /app/config` — потолок размера тем же числом, каким сервис
ограничивает тело запроса, частота опроса, перечень известных расширений,
потолок тем
- [x] 5.3 `GET /app/audiorecords` — страница своих записей новыми сверху, с
ключом следующей страницы и общим числом; отбор тремя состояниями
- [x] 5.4 `GET /app/audiorecords/{id}` — карточка записи без текста, с перечнем
доступных видов текста `available_views`; пустой перечень значит «текста ещё
нет»
- [x] 5.5 `GET /app/audiorecords/{id}/text` — текст названного вида из закрытого
перечня `transcript`, `literary`, `replicas`; неизвестный и незаданный вид
дают `400`, отсутствующий у записи вид — `409`
- [x] 5.6 `POST /app/audiorecords` — ответ списком с местом под признак
повторного файла, элемент той же формы, что и карточка
## 6. Отбор и чтение в хранилище
- [x] 6.1 Добавить в репозиторий записей выборку по владельцу курсором на паре
«время заведения и идентификатор», новыми сверху, с умолчанием и потолком
размера страницы
- [x] 6.2 Проверить тестом, что выборка списка не читает ни строки текста, ни
структуры реплик
- [x] 6.3 Разрешить темы записи названиями одним запросом на страницу, а не по
запросу на запись
- [x] 6.4 Вывести предикаты трёх состояний отбора («в работе», «остановлена»,
«прошла конвейер») из дескриптора рубежа в `internal/entity`, а не строкой
фильтра в репозитории
- [x] 6.5 Дописать правило `internal/archrules` на нового потребителя словаря
рубежей — отбор списка
## 7. Документация и гейт
- [x] 7.1 `docs/database.md` — три новые колонки записи
- [x] 7.2 `docs/conventions/web-ui.md` — правило неизвестного пути перечисляет
корни сервиса (проверить, что запись уже верна)
- [x] 7.3 `CLAUDE.md`, инвариант «Принятая запись не теряется молча» — причина
видна владельцу **карточкой записи**, а не опросом готовности: опрос убран, и
инвариант ссылается на адрес, которого нет
- [x] 7.4 `docs/passport.md` — сценарии потребителей называют `POST /api/audio` и
`GET /api/status/:id` действующими; задача `api-tokens` прочитает их как
действующие и заведёт токен на несуществующий адрес
- [x] 7.5 `docs/security.md` — периметр и таблица поверхностей перечисляют
убираемые адреса и не знают ни одного адреса приложения, а изменение добавляет
их несколько
- [x] 7.6 `docs/architecture.md` — строка компонента «HTTP API» и строка «кто
заметит отказ» называют опрос готовности; там же строка про `done` без текста
- [x] 7.7 Обновить мутационную проверку `journal_route_test.go`: пути `/api/audio`
и `/api/status/...` в ней заменены новыми адресами приложения
- [x] 7.8 `task gate` зелёный целиком
- [x] 7.9 Поднять сервис локально: шаг схемы применён на чистой базе, адреса
приложения подняты, отказ без сессии идёт единой формой, прежние адреса
отвечают `404`. Сценарий вошедшего локально не проходится по устройству
проекта: сессию выдаёт только провайдер OIDC, а локальный запуск наружу не
ходит — этот путь закрыт машиной в тестах, через настоящий роутер
## Критерии приёмки
Два источника, и приёмка судится по одному списку. Первый блок — из записи
задачи `json-api-for-spa` дословно: файл задачи закрытие удалит, критерии
обязаны его пережить. Второй — рубрика ревью дизайна, дополняющая их там, где
постановка молчала.
### От постановки
- Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при
чтении даёт `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`; текст вида `replicas`;
прежние адреса `GET /api/status/{id}` и `POST /api/audio` на заведённой записи
отвечают `404`.
*Правка от 2026-08-15:* критерий приведён к спеке после повторной сверки.
Прежняя формулировка требовала признак наличия текста и «тот же вид репликами»
— обе воскрешали то, что ревью дизайна уже закрыло: булев признак не выражает
состояния «текст есть, реплик нет», а `transcript` репликами не отдаётся, у них
своё значение перечня.
### От рубрики ревью дизайна
- Страница задаётся непрозрачным ключом на паре «время заведения и
идентификатор», и запись, заведённая между двумя страницами, окна не сдвигает.
Оракул — два теста: прочитать первую страницу, завести запись, прочитать
следующую ключом — ни повторов, ни пропусков; две записи с одинаковым временем
заведения приходят в одном и том же порядке при повторном запросе.
- Карточка и элемент страницы — одна форма, а элемент ответа приёма равен
карточке плюс признак повтора. Оракул — два теста: набор полей карточки
совпадает с набором полей элемента страницы, кроме `available_views`; набор
полей элемента ответа приёма равен набору полей карточки плюс `duplicate`.
- Размер страницы: умолчание применяется, значение сверх потолка усекается,
негодное отвергается. Оракул — три теста: запрос без размера; запрос размером
сверх потолка; запрос размером ноль даёт `400`.
- Отбор различает три состояния, и остановленная запись приходит ровно в одном
из них. Оракул — тест на трёх записях: в работе, остановленная, прошедшая
конвейер; каждая приходит ровно один раз.
- «Текста этого вида ещё нет» отличимо от «записи нет». Оракул — тест: запись с
расшифровкой и без структуры на вид `replicas` даёт `409`, а неизвестный
идентификатор — `404`.
- Отказ по превышению потолка размера проходит через единую форму. Оракул —
тест: тело сверх `entity.MaxRecordSize` даёт `413`, а тело ответа несёт код
отказа, сообщение и предел числом.
- Колонки названы с единицей в имени. Оракул — шаг схемы заводит `duration_ms` и
`size_bytes`; тест приёма проверяет, что обе величины доходят до записи.
*Правка от 2026-08-15:* требование «неизвестная длительность отличима от
нулевой» снято решением владельца после ревью кода. Числовая колонка хранилища
пустого значения не держит — проверено прогоном, — а платить за отличимость
четвёртой колонкой или текстовым типом не за что: обе величины ставит приём и
ставит всегда.
- Отсутствие сессии не даёт различить заведённую запись и неизвестную. Оракул —
тест: оба запроса без сессии дают `401` с одним телом.
- Карточка остановленной записи несёт рубеж, признак остановки и её причину, а
машинного текста отказа не несёт. Оракул — тест на остановленной записи.