хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-22
@@ -0,0 +1,322 @@
## Context
Сегодня встроенное хранилище держит в этом коде шесть ролей сразу, и только две
из них про хранение. Что именно оно держит, чем за это плачено и чем платит
уход — измерено разведкой
[docs/research/storage-without-pocketbase.md](../../../docs/research/storage-without-pocketbase.md);
варианты («оставить как есть», «уйти целиком», «уйти в два шага», «локализовать
протечку») названы там же таблицей, а выбор и причины отказа от остальных
записаны решением
[ADR-2026-08-22-storage-without-pocketbase](../../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md).
Второй раз здесь они не переписываются.
Обстоятельство, которое делает работу дешёвой именно сейчас: стадия проекта —
стройка, на сервере данных нет, сервис остановлен. Раскладка каталога данных
названа необратимой, и цена её смены сегодня нулевая.
Ниже — только те решения, которых в ADR и разведке нет: они выбирают форму, а
не направление.
## Goals / Non-Goals
**Goals:**
- Человек, открывший приложение, не замечает смены вовсе: адреса приложения,
формы запросов и ответов остаются прежними.
- Второго периметра на порту сервиса не остаётся: всё, что отвечает, написано
нами и судится нашими правилами.
- Файл записи достаётся только его владельцу, и право пройти по ссылке даёт
узнавание, а не значение, выданное на предъявителя.
- Захват записи остаётся неделимым, и результат по-прежнему пишет только
держатель захвата.
- Сервис поднимается на пустом каталоге данных сам, без ручного шага.
**Non-Goals:**
- Замены панели владельца это изменение не приносит: экраны правки записи и
прослушивания несут отдельные задачи. До них у владельца сервиса остаётся одно
действие — возврат остановленной записи в работу, — и делает его подкоманда
оснастки, а не экран.
- Переноса прежних данных нет: переносить нечего.
- Загрузка частями, узнавание записи по хеш-сумме, удаление записи и вторая
копия рядом становятся выполнимыми, но этим изменением не делаются.
- Состав полей карточки и списка не меняется ни одним полем.
## Decisions
### Файлы записей лежат каталогом на запись
Каталог данных получает раздел записей, и каждой записи в нём принадлежит свой
подкаталог, названный её идентификатором. Внутри лежат копии — принятая и
приведённая, — под именами, которые задаёт сервис.
Что человек увидит иначе: ничего. Что станет возможным — вторая копия рядом с
первой, дозапись частями и уборка записи целиком одним движением; сегодня всё
это упирается в чужую раскладку.
Рассмотрено и отвергнуто:
- **плоский каталог, имя файла — идентификатор с расширением.** Отвергнут: две
копии одной записи в плоском каталоге различаются только приставкой в имени, и
уборка записи превращается в перебор по маске;
- **раскладка по первым знакам идентификатора** (как у git). Отвергнута: она
лечит переполнение каталога тысячами имён, а расчётная нагрузка — единицы
записей в день; платить за это нечитаемыми путями сегодня не за что.
### Файл отдаётся адресом приложения, а не адресом хранилища
Файл записи уходит адресом под корнем приложения, и вид копии называет
спрашивающий — тем же способом, каким называется вид текста. Обработчик судит
владельца записи и отвечает на чужую и на несуществующую одинаково.
Что человек увидит иначе: короткого токена файла больше нет, и порядок из трёх
шагов («узнавание → токен → ссылка») становится одним шагом. Отзыв доступа
доходит до файла сразу, а не через срок жизни токена.
Рассмотрено и отвергнуто:
- **своё значение на предъявителя, выдаваемое узнанному** — то, что было.
Отвергнуто: узнавание идёт на каждом запросе, второе значение ничего не
добавляет и создаёт окно, в котором отозванный доступ ещё работает;
- **ссылка со сроком и подписью** (как у объектных хранилищ). Отвергнута: та же
цена, что и у токена, плюс свой секрет подписи, которого у сервиса нет.
### Шаги схемы двигает `goose`, а не свой раннер
Шаги схемы накатывает `github.com/pressly/goose/v3` — **библиотекой, а не
командной строкой**: каталог шагов вшит в бинарник, провайдер заводится в точке
входа и получает пишущий пул базы, накат идёт до подъёма входов и до старта
воркеров, отказ шага роняет старт. Ни отдельного исполняемого файла, ни своего
шага выкладки не заводится.
Инструмент выбран решением владельца 2026-08-22. Он уже был в этом проекте и
ушёл вместе с PocketBase: возврат не приносит нового знания, а снимает часть
работы.
Из трёх норм, которые накат обязан выполнять, `goose` даёт две — и даёт по
факту, а не по обещанию:
- **шаг и отметка о нём идут одной транзакцией.** Накат одного шага открывает
транзакцию на том же соединении и внутри неё выполняет и сам шаг, и вставку
версии в таблицу учёта (`provider_run.go`: `beginTx` вокруг `runMigration` и
`maybeInsertOrDelete`). Отменяет это только сам шаг — пометкой `-- +goose NO
TRANSACTION`, — и мы её не ставим;
- **порядок шагов детерминирован.** Версия шага читается числом из имени файла
до первого знака подчёркивания (`NumericComponent` в `migration.go`), а
собранные шаги сортируются по этому числу (`sort.Slice` в
`provider_collect.go`). Порядок обхода каталога на исход не влияет: его
результат пересортировывается. Две одинаковых версии дают отказ сбора с обоими
путями, а не молчаливый выбор одной.
Третьей нормы — **исключающей блокировки наката**`goose` под SQLite не даёт
вовсе. Пакет `lock` поставляет два запирателя, и оба для PostgreSQL:
`NewPostgresSessionLocker` и `NewPostgresTableLocker`; интерфейсы `SessionLocker`
и `Locker` объявлены вместе с отказом `ErrLockNotImplemented`, а провайдер,
которому запиратель не задан, накатывает без всякой блокировки.
Блокировка остаётся нашей заботой и закрывается замком на файле в каталоге
данных: `syscall.Flock` с `LOCK_EX` берётся до наката и снимается после, а с
умершим процессом его снимает ядро — просроченного замка, который надо чистить
руками, не остаётся. Второй процесс, поднятый на том же каталоге, ждёт замка
либо отказывает. Норму держит требование capability `storage` «Сервис
поднимается на чистом каталоге данных», и написана она про поведение, а не про
инструмент.
Что человек увидит иначе: ничего, пока всё цело. Отказ схемы становится отказом
старта, а не отказом каждого запроса потом.
Рассмотрено и отвергнуто:
- **свой раннер** — чтение каталога шагов, своя таблица учёта, своя сортировка.
Отвергнут: три нормы выше ему пришлось бы выполнять самому, две из них
`goose` уже выполняет, а третья остаётся нашей при любом выборе. Плата за
свой раннер — код, который надо писать, отлаживать и держать, — вносится ради
экономии одной зависимости, уже бывшей в проекте;
- **схема одним файлом, приводимым к желаемому виду при старте.** Отвергнута:
инвариант проекта требует версионированных шагов и запрещает переписывать
применённый.
### Прежние шаги схемы заменяются одним шагом начальной схемы
Применённые шаги PocketBase удаляются каталогом целиком, и на их месте встаёт
один шаг, заводящий схему сразу такой, какая нужна. Это **снятие инварианта
проекта «Миграция, уехавшая на сервер, не переписывается»** — разовое, решением
владельца от 2026-08-22.
Причина: стадия проекта — стройка, на сервере данных нет, сервис остановлен,
выкладка пойдёт с чистого листа. Переписывать нечего: ни один из прежних шагов
не применён ни к одной живой базе, а новая база заводится другим инструментом и
другой таблицей учёта — отметки прежнего каталога ей не годятся вовсе.
Граница: снятие разовое и кончается этим изменением. Шаг начальной схемы, уехав
на сервер, подпадает под инвариант как всякий прежний — менять его можно будет
только новым шагом. Шаг гейта `migrations` при этом обязан смотреть на новый
каталог: оставленный на старом, он прочитает удаление прежних файлов как
переписывание применённого и покраснеет.
### База принимает одного писателя
Соединение для записи одно, чтение идёт своим пулом; журнал упреждающей записи
включён, соблюдение внешних ключей включено, ожидание занятой базы задано числом.
Все три задаются строкой подключения обоих пулов, а не запросом после открытия:
две из трёх настроек в SQLite принадлежат соединению, а не базе, и пул заводит
новые соединения по мере надобности. Числа переезжают в дом числовых настроек
проекта.
**Два числа заводятся ключами конфига сразу, этим же изменением:**
- `[storage] busy_timeout_ms` — сколько ждать занятую базу, миллисекунды;
- `[storage] read_connections` — сколько соединений держит читающий пул.
Оба крутят при одном и том же отказе — «база занята» под несколькими
воркерами, — и число воркеров у сервиса уже настраивается. Константами кода они
означали бы, что подбор ответа на этот отказ требует пересборки образа.
Операция, которая читает и следом пишет, идёт целиком на пишущем соединении:
читающую транзакцию SQLite до пишущей не повышает и отказывает по занятости сразу,
не дожидаясь заданного числом срока. Так идут захват записи и накат шага схемы.
Узнавание устроено иначе, и это решение владельца от 2026-08-23. Учётная запись
ищется читающим пулом, а пишущая транзакция открывается только когда поиск ничего
не нашёл; окно между двумя соединениями закрывает повторный поиск внутри
транзакции, а уникальность держит схема. Прежнее устройство брало писателя на
каждом запросе приложения — включая опрос карточки и запрос куска записи, — и
цена этого измерена: очередь к единственному пишущему соединению ожиданием
занятой базы не ограничена и отказом не кончается, а ждёт столько, сколько
занят писатель.
Что человек увидит иначе: ничего, пока настройка верна. Ошибка здесь видна
отказами «база занята» под несколькими воркерами — тем самым, что чужая
библиотека держала за нас двумя пулами, — а ошибка во внешних ключах не видна
вовсе: ничья запись просто заводится.
Рассмотрено и отвергнуто: **один пул на всё** — отвергнут разведкой,
измерившей, что драйвер пишет единственным соединением.
### Пакет хранилища называется `internal/adapter/repo/sqlite`
Пакет `internal/adapter/repo/pocketbase` уходит целиком, и на его месте встаёт
`internal/adapter/repo/sqlite`. Имя выбирается здесь, а не при написании кода: оно
разойдётся по импортам, по правилам `internal/archrules`, которые указывают в
пакет строкой, и по документам канона — переименование после этого стоит дороже
самого выбора.
Названо по драйверу, а не по роли: соседи в `internal/adapter` названы тем же
способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite» читается как
«репозитории поверх SQLite» без знания кода.
Рассмотрено и отвергнуто: **`internal/adapter/repo/db`** — не называет ничего, а
второй драйвер рядом с ним пришлось бы назвать по имени, и пара вышла бы
разнородной.
### Захват остаётся одним запросом с возвратом
Выбор пригодной записи и пометка её захваченной идут одним запросом, и он же
возвращает идентификатор записи и признак этого захвата. Признак уникален для
каждого захвата, и запись результата условна по нему.
Решение подтверждается, а не принимается заново: замер, которым выбрана очередь
таблицей, снят на том же драйвере, который остаётся после ухода. Отменяется одно
слово — таблица перестаёт быть коллекцией.
### Ограничитель частоты становится своим
Бюджет считается по адресу спрашивающего под корнем приложения. Объявленная
приложению частота опроса карточки по-прежнему выводится из доли этого бюджета,
а не из своей константы.
Что человек увидит иначе: включение нашего правила перестаёт вводить в действие
чужие правила на чужой поверхности — их больше нет.
### Панель не заменяется ничем, а возврат в работу делает подкоманда оснастки
Владелец сервиса теряет панель, и заменяющего её экрана это изменение не
приносит. Единственное действие, которое он делал панелью и которое нельзя
отложить до экранов, — возврат остановленной записи в работу — переезжает в
набор инструментов разработчика, `cmd/devtools`, отдельной подкомандой. Она
открывает базу того же каталога данных, зовёт домен и пишет событие журнала
записи с происхождением «человек».
Причины две, и каждая своя.
Первая: возврат в работу — не одно поле. Сбросить надо признак остановки, признак
захвата и срок его протухания, число отказов, паузу и время входа в рубеж; норму
держит capability `pipeline`. Рука, забывшая любое из них, оставляет запись либо
невидимой для захвата, либо останавливаемой снова первым же захватом — молча, без
единой строки. Подкоманда колонок не пишет: правило исполняет домен, а она
назначает порядок шагов.
Вторая: `entity.EventOriginHuman` теряет иначе единственного писателя — его писала
панель. Происхождение события в журнале записи стало бы константой, и различие
«это сделал конвейер» против «это сделал человек» перестало бы значить что-либо.
Что человек увидит иначе: вместо таблицы с полями у владельца одна команда с
одним предметом. Экраны приносят отдельные задачи, и подкоманда живёт до них.
Рассмотрено и отвергнуто: **правка строки в базе руками** — то, чем возврат
описывался до чекпоинта. Отвергнута: перечисленные выше поля человек за
клавиатурой сбрасывает по памяти, а событие журнала записи не пишется вовсе.
### Колонок `location` и `source` в новой схеме нет
Обе колонки заводятся сегодня, пишутся одним значением и не читаются никем.
`location` у сущности файла: туда пишется `local`, и второго значения (`s3`) не
пишет ни один шаг. Ветвления по нему в коде нет.
`source` у аудиозаписи: всякий приём пишет туда `api`. Второе значение,
`telegram`, держалось не потребителем, а ссылкой из применённого шага схемы —
вход Telegram убран 2026-08-14, и константа осталась только потому, что
применённый шаг не переписывается. Шаги уходят, и держать её больше нечем.
Ни одной колонки в новой схеме не заводится, и поля уходят из сущностей домена и
из их отображения в строки базы. Цена возврата названа прямо: поле, у которого
появится читатель — вторая копия в объектном хранилище либо второй вход, —
вернётся одним новым шагом схемы, и платится это тогда, а не сейчас.
### Колонки записи пишутся и читаются по имени
Отображение сущности в строку базы работает **именованными параметрами** запроса
и сканированием **по имени колонки**, а не позиционными списками.
Причина в самой сущности: у аудиозаписи поля одного типа — необязательной
строки — идут длинным непрерывным рядом, и ссылки на файл, на структуру реплик,
на два вида текста и на попытку распознавания стоят в нём подряд. Позиционный
список даёт сдвиг на одно поле, который компилируется молча и кладёт
идентификатор файла в колонку текста. По имени такого сдвига не существует
вовсе: лишнее имя или недостающее — отказ запроса, а не тихая подмена значения.
Инвариант проекта о колонках записи, правящихся в двух местах сразу, эта форма не
снимает: колонку по-прежнему можно забыть в отображении или в шаге схемы. Она
снимает **другую** поломку — ту, где колонка не забыта, а перепутана местом.
Сторожа инварианта в `internal/archrules` переписываются под эту форму: сегодня
они построены на динамической записи по имени колонки в API уходящей библиотеки.
## Risks / Trade-offs
- **Владелец остаётся без панели, а экранов ещё нет** → возврат остановленной
записи в работу делает подкоманда `cmd/devtools`, а прочая правка ждёт экранов;
сервис на стройке, живых записей нет, и цена ограничена этим окном.
- **Раскладка каталога данных меняется необратимо** → цена нулевая сегодня и
перестаёт быть нулевой после первой боевой записи; смена сделана до выкладки.
- **Отдача файла написана нами и может отдать чужое** → правило одно: обработчик
судит владельца записи, а чужая запись отвечает тем же, чем несуществующая;
проверка на это стоит критерием приёмки.
- **Единственный писатель настроен неверно** → отказы «база занята» под
несколькими воркерами; ожидание и размер читающего пула заданы ключами
`[storage] busy_timeout_ms` и `[storage] read_connections` и стоят в доме
числовых настроек проекта.
- **Шаг схемы не накатился, а сервис поднялся** → накат идёт до подъёма входов,
и его отказ роняет старт; критерий приёмки требует чистого журнала до строки о
готовности.
- **Два процесса накатывают схему на одном каталоге** → блокировки под SQLite
`goose` не даёт, и замок на файле берём сами; забытый замок виден только на
чистой базе, которую заводят один раз, поэтому проверка на два одновременных
наката стоит критерием приёмки.
- **Прежние адреса продолжают отвечать чем-то посторонним** → пути хранилища и
панели перестают быть корнями сервиса и подпадают под общее правило
неизвестного пути; критерий приёмки проверяет и подменённый знак в адресе
панели.
- **Норма отказа «узнан, но учётной записи нет» теряет свой единственный
случай** → ветвь снимается вместе с ним; вернуть её придётся задаче о личных
токенах, и там же она получит свой случай.
@@ -0,0 +1,96 @@
## Why
Встроенное хранилище куплено ради трёх вещей — панели владельца, файлов рядом с
базой и входа через его провайдера, — и третья отпала 2026-08-22: пришедшего
называет обратный прокси, а учётную запись заводит сам сервис. Оставшееся
хранилище держит не только хранение: оно же маршрутизатор, цепочка слоёв,
ограничитель частоты, отдача файлов и второй периметр на том же порту. Второй
периметр опубликован в интернет вместе с панелью, его барьер обходится подменой
знака в адресе, а закрыть его целиком нельзя — за файлами записей туда ходит
браузер человека.
Момент назначен обстоятельством: на сервере данных нет и сервис остановлен,
поэтому смена стоит только кода. Дешевле она не станет — код входов прирастает
чужими типами с каждой задачей.
## What Changes
- Сервис работает с базой напрямую и заводит свою схему своими шагами, двигая их
библиотекой `goose`. Раскладка каталога данных меняется — **BREAKING**,
необратимое.
- Файлы записей ложатся в свой каталог со своей раскладкой и отдаются своим
адресом сервиса. Право пройти по ссылке даёт узнавание и владение записью, а
не короткий токен, выданный хранилищем на предъявителя.
- Панель администратора исчезает и **не заменяется ничем****BREAKING**.
Остановленную запись возвращает в работу подкоманда набора инструментов
разработчика — через домен и с событием журнала записи, — а экраны владельца
приносят отдельные задачи.
- Второго адресного пространства у сервиса не остаётся: адреса хранилища и адрес
панели перестают существовать — **BREAKING**. Корень приложения остаётся тем
же, и контракт приложения не меняется ни одним полем.
- Собственного входа у хранилища больше нет — вместе с самим хранилищем, — и
требования, закрывавшие его наглухо, теряют предмет. Узнавание остаётся
прежним: заголовок доверенного источника на каждом запросе.
- Пароль владельца от панели пропадает — секрет, которого до перевода не было.
- Очередь остаётся своей таблицей, а захват — одним неделимым шагом с
возвратом идентификатора и признака захвата.
- Ограничитель частоты под корнем приложения становится своим и перестаёт зависеть
от настроек чужой поверхности.
## Capabilities
### New Capabilities
Новых capability изменение не заводит: предмет тот же, меняется его норма.
### Modified Capabilities
- `storage`: раскладка каталога данных и шаги схемы становятся своими; файл
отдаётся адресом сервиса с проверкой владельца вместо ссылки хранилища с
коротким токеном; требования о панели владельца, о пароле от неё и о закрытии
собственной поверхности хранилища снимаются вместе с предметом; закрытость
содержимого переписывается со словаря коллекций и правил доступа на словарь
таблиц и адресов сервиса.
- `access`: требование, выключавшее собственные входы хранилища, снимается вместе
с ними; область узнавания перестаёт обходить чужую поверхность и перестаёт
включать выдачу токена файла; запрет на печать значения, дающего доступ,
теряет упоминание короткого токена.
- `archive`: адреса приложения перестают соседствовать с чужим пространством —
соседа больше нет; ограничитель частоты под корнем приложения становится
своим, и объявленная частота опроса по-прежнему выводится из его бюджета.
- `intake`: имя файла в хранилище задаёт сервис, и умолчания, которое строило имя
из имени отправителя, больше не существует; отказ узнанному без учётной записи
теряет свой единственный случай — владельца панели с собственным токеном.
- `webapp`: перечень корней сервиса, из которого выводится правило неизвестного
пути, теряет корень хранилища и корень панели; журнал у сервиса остаётся один.
- `pipeline`: возврат остановленной записи в работу перестаёт быть правкой в
панели и становится подкомандой оснастки, идущей через домен; перечень полей,
которые возврат обязан сбросить, называется целиком и в одном месте, и к нему
добавляется обязанность писать событие журнала записи; норма о том, что шаг
пишет только свои поля, остаётся в силе с прежней ценой.
- `recognition`: сохранённый ответ провайдера переезжает из вложения чужого
хранилища в отдельный файл каталога данных, а его закрытость переписывается с
пометки поля и правила просмотра коллекции на проверку владельца в обработчике
сервиса. Зачем ответ хранится целиком и где лежит попытка распознавания, смена
хранилища не трогает.
## Impact
- Публичный контракт HTTP: пространство адресов хранилища и адрес панели
исчезают, корень приложения остаётся. Формы запросов и ответов приложения не
меняются.
- Раскладка каталога данных: файл базы и каталог файлов записей — необратимое,
цена сегодня нулевая (стройка, на сервере пусто).
- Модель угроз: из периметра уходят панель, её пароль, дефект с подменой знака в
адресе и правило прокси на чужое пространство; приходит своя отдача файла и
свой ограничитель частоты.
- Инструмент владельца: панели нет; возврат остановленной записи в работу делает
подкоманда `cmd/devtools`, прочая правка ждёт экранов.
- Зависимости сборки: библиотека хранилища и всё, что достижимо только через
неё, уходят; драйвер базы без CGO остаётся; приходит `pressly/goose/v3`
библиотекой, без командной строки.
- Настройки: ключ каталога данных остаётся, ключей панели не заводится, и
заводятся два ключа базы — ожидание занятой базы и число соединений чтения.
- Шаги схемы: применённые шаги прежнего каталога удаляются целиком и заменяются
одним шагом начальной схемы — разовое снятие инварианта «применённая миграция
не переписывается» решением владельца от 2026-08-22.
@@ -0,0 +1,93 @@
# Отчёт триажа ревью кода — storage-without-pocketbase
Дата: 2026-08-23
Режим: прогон по графу, база диффа HEAD, работа незакоммичена. Метка large — задана владельцем прогона, оси не мерились. Гейт зелёный целиком. Сигнал о метке от review-code: large подтверждена. Находок на входе 27 именованных плюс ~20 подпороговых; осталось 7 в основных секциях, 2 понижены в гипотезы, 8 в урожай, 2 в promote.
План и исход по темам: requirements (дельта-спеки + openspec/specs, разбор, specs) — закрыта, 6 находок; autotests (CLAUDE.md «Гейт», autotests) — закрыта, 4 находки; conventions (docs/conventions/, разбор, code) — закрыта, 6 находок, потолок 4/4 сработал; architecture (docs/architecture.md + passport.md, доказательство, architecture) — закрыта, 3 находки, потолок 3 сработал; security (docs/security.md, доказательство, adversary) — закрыта, 5 находок, пути построены и прогнаны; operations (docs/architecture.md «Эксплуатация» + docs/database.md, доказательство, ops) — закрыта, 3 находки с замерами. Тем без отчёта нет. basics не запускался: своих тем проекта нет, темы ядра разобраны именными проходами.
## 1. Блокирует мердж
### Аноним пишет свой текст в журнал владельца со скоростью 121 МиБ/с, и ограничитель этому не мешает
Файл: internal/controller/http/journal.go:80-95 (JournalRoute), :55-75. Severity: critical. Confidence: high.
Оракул: TestAdversary_AnonymousWritesIntoJournalUnderAppRoot — строка журнала несёт путь дословно при ответе 401. TestAdversary_JournalLineGrowsWithRequestedPath: путь 1 044 480 знаков → прирост журнала 1 044 632 байта. TestAdversary_JournalThroughput: одно соединение, 1.003 с → 122 запроса, 121.5 МиБ журнала (121.2 МиБ/с). TestAdversary_RefusedRequestStillWritesJournal: 120 отказов ограничителя → 240 строк журнала, 1 019 617 байт: слой журнала стоит снаружи ограничителя. Конвенция docs/conventions/logging.md:113 запрещает это дословно.
Последствие: неузнанный снаружи наполняет журнал контейнера своим текстом с произвольной скоростью. Диск сервера общий с data/ — под ним и база, и записи живых людей; исчерпание места приходит на укладку записи, где причина отказа к тому же теряется. Собранные логи чужой текст уносят навсегда.
Предложение: JournalRoute возвращает webappRoute для всего, что накрыто корнем приложения, а не только для чужих путей; длину отдавать полем http.path_length. Точные адреса (/health, /metrics) пишутся дословно — они из закрытого перечня.
Найдено проходом: adversary (V2), code/конвенции (C3). Действие: инлайн.
### Ограничитель частоты ключуется значением, которое пишет сам спрашивающий: бюджет обходится с первого запроса
Файл: internal/controller/http/rate_limit.go:86-101 (clientAddress). Severity: critical. Confidence: high.
Оракул: TestAdversary_RateLimitBypassedByForwardedFor — ограничитель пропустил 1200 запросов одного спрашивающего при бюджете 120. TestAdversary_RateLimiterBudgetsGrowth — 200 000 ключей в карте, прирост кучи 19 810 376 байт (99 байт на ключ). Код берёт strings.Cut(r.Header.Get(ForwardedForHeader), ",") — левое значение цепочки, а Caddy заголовок по умолчанию дописывает, а не заменяет.
Последствие: единственный бюджет на корне приложения не действует ни для одного противника, знающего про заголовок. Ключ карты выбирает он же, поэтому карта растёт линейно от числа выдуманных адресов. Складывается с предыдущей находкой: обход бюджета снимает потолок, который мог бы ограничить поток в журнал.
Предложение: сканировать X-Forwarded-For справа налево, отбрасывая доверенные адреса, и брать первый недоверенный; читать r.Header.Values, а не Get. Отдельно — предел числу ключей карты.
Найдено проходом: adversary (V1), specs (S5). Действие: инлайн.
### Инвариант про колонки записи потерял предмет: он называет функции, которых в коде больше нет, а третье место чтения не механизировано ничем
Файл: CLAUDE.md:105, internal/adapter/repo/sqlite/record_mapping.go:111-173, internal/archrules/arch_test.go:333-361. Severity: major. Confidence: high.
Оракул: grep по applyOwnedByPipeline/applyToRecord/recordToAudioRecord — пусто, при том что инвариант называет ровно эти три имени. grep rowToAudioRecord в internal/archrules — пусто: правило сверяет writeOwnedByPipeline+writeRecord против readRecordColumns, а функция, заполняющая сущность, в предмет правила не входит.
Последствие: мест стало три (readRecordColumns — что спрошено, recordRow — куда лягут, rowToAudioRecord — что доедет до сущности), механизировано одно. Колонка, забытая в rowToAudioRecord, даёт зелёный гейт: значение не доезжает до сущности, а ближайший Save пишет нулевое поверх сохранённого — тихая порча данных, ровно та, которую инвариант объявляет закрытой. Сам инвариант стал неверифицируемым.
Предложение: обновить текст инварианта на действующие имена и действительное число мест; расширить правило internal/archrules на rowToAudioRecord.
Найдено проходом: architecture (R1). Действие: инлайн.
## 2. Стоит исправить сейчас
### Всякий запрос берёт пишущую транзакцию единственного пишущего соединения, а очередь к нему не ограничена ничем
Файл: internal/adapter/repo/sqlite/identity.go:40-58, db.go:56-64,114-118, internal/controller/http/identity.go:125. Severity: major. Confidence: high.
Оракул: падающий тест триажа — при BusyTimeoutMs 100 и занятом пишущем соединении EnsureUser ждал 606.494 мс и вернул nil: ожидание свободного соединения busy_timeout_ms не ограничено вовсе, границу задаёт только контекст, а контекст — context.Background(). EnsureUser зовётся слоем узнавания, одетым на весь корень приложения, значит BEGIN IMMEDIATE берётся на 100% узнанного потока. Требование storage/spec.md:26-32 составной операцией называет заведение учётной записи первым обращением, а не всякое.
Последствие: зависший (не отказавший) диск останавливает приём, опрос карточек и конвейер разом, без предела ожидания; сторож застревания стоит в той же очереди. Отказа не будет — будет тишина.
Предложение: искать учётную запись читающим пулом и уходить в пишущую транзакцию только когда не нашлось; ветвь повторного поиска после гонки уже написана (identity.go:74-80).
Найдено проходом: ops (O1), specs (S1), architecture (R3). Действие: развилка — вопрос владельцу, три варианта: (а) снять с горячего пути только узнавание; (б) то же плюс предел ожидания на обращениях, рождённых HTTP-запросом; (в) оставить и записать цену нормой в спеку storage.
Оговорка: довод «NoopJobError не считается, поэтому конвейер встал и очередь пуста неотличимы» опирается на то, что считать NoopJobError запрещено инвариантом. Законна только просьба про heartbeat воркера — она в урожае.
### Диагностика укладки настроена ровно наоборот: причина отказа отброшена там, где нужна, и путь внутри каталога данных уехал в журнал там, где не должен
Файл: internal/adapter/repo/sqlite/store.go:64-112 (Put, Open, Remove), internal/service/transcribe.go:222-224. Severity: major. Confidence: high.
Оракул: падающий тест триажа. Отказ записи вернул: failed to store a copy of record 01M0NV5JFP5AYNVKR5FF8FW9HR / write /tmp/.../records/01M0NV5JFP5AYNVKR5FF8FW9HR/.partial-01m0nvcvk21w1qz0qhhy54bry4: file too large — полный путь внутри каталога данных в цепочке, при том что комментарий store.go:80-81 утверждает обратное. Отказ по правам вернул failed to create the directory of record …, и os.IsPermission(err) = false: причина отброшена целиком. Конвенция errors.md:26-34: обёртка %w — умолчание.
Последствие: у владельца единственная поверхность диагностики, и на ней ENOSPC, EACCES и EROFS неразличимы — сервис говорит одно и то же на три поломки, требующие трёх разных действий. Одновременно одна ветвь из семи делает обратное — кладёт полный путь в журнал.
Предложение: во всех семи местах обернуть причину %w, сохранив errors.Is до fs.ErrPermission и syscall.ENOSPC; путь снять — заворачивать не *os.PathError целиком, а его .Err.
Найдено проходом: code (C4 — причина), specs (S3 — путь). Чинить порознь нельзя: вторая правка отменит первую. Действие: инлайн.
### Тип содержимого ответа выбирает отправитель: запись.html отдаётся text/html; charset=utf-8 с inline
Файл: internal/controller/http/file.go:16-33,119-126,171-180. Severity: minor. Confidence: high.
Оракул: TestAdversary_HostileExtension. запись.html → Content-Type text/html; charset=utf-8, Content-Disposition inline; запись.svg → image/svg+xml inline; запись.xhtml → application/xhtml+xml. Перечень contentTypes не знает mkv/mov/avi, которые сервис сам объявляет диалогу выбора файла, и откатывается на mime.TypeByExtension — в alpine нет /etc/mime.types, у разработчика есть: ответ становится функцией машины сборки. Инвариант CLAUDE.md: наружу расширение выходит только приведённым к перечню известных форматов; единая точка metrics.FormatLabel существует, транспорт ходит мимо неё.
Последствие: сегодня цена нулевая — файл видит только владелец. Появляется у первой задачи со вторым читателем, и появляется молча. Плюс уже действующая ошибка: mkv/mov/avi отдаются типом, зависящим от образа.
Предложение: тип содержимого выводить из закрытого перечня той же единой точки, что и метку метрики; всё, чего в перечне нет, — application/octet-stream с attachment; откат на mime.TypeByExtension убрать.
Найдено проходом: code (C1), architecture (R2), adversary (V4). Действие: инлайн. Вторая половина R2 — своя реализация диапазонов против http.ServeContent — в урожай.
### Три решающих ветви отказа не проверены ничем, и одна из них — та, что держит процесс живым
Файл: internal/config/config.go:88-98, internal/controller/http/errors.go:184-190, internal/controller/http/app.go:459-462,490-493. Severity: major. Confidence: high.
Оракул: go tool cover -func = 0.0% на всех трёх местах, grep по тестам пуст. Все три ветви StorageConfig.Validate() не покрыты, config_test.go не упоминает StorageConfig вовсе; Recover не вызывается ни одним тестом; contract.ErrTextNotReady не проверяется во всём пакете, включая отображение в 409.
Последствие: журнал проекта знает три записи класса «проверка не могла упасть» и «тесты обработчика ни разу не были зелёными» (docs/review.md, 2026-08-10, 2026-08-11, 2026-08-15). Здесь тот же класс на новом коде.
Предложение: три теста — таблица на три ветви Validate, обработчик с паникой через полную цепочку слоёв, запрос текста у записи без готового текста с проверкой кода 409 и тела.
Найдено проходом: autotests (A1, A2, A3). Действие: инлайн.
## 3. Гипотезы без доказательства
A4 — класс «репозиторий отказал во время запроса» не проверен нигде (minor). Оракула нет: инъекции отказов в HTTP-тестах не существует. Понижено до наблюдения.
V5 — владение судится у записи, а файл открывается по её ссылке без сверки files.record_id/files.owner_id (minor, свойство без пути). Находка о будущем: delete-record и long-audio-chunking будут править обе стороны. В урожай.
S2 — goose_db_version.tstamp несёт вид времени и умолчание вне объявленной нормы (minor). Исход — выбор нормы, не правка кода. В урожай развилкой.
## 3б. Урожай — реальное, но не для этого мерджа
1. /%6detrics отдаёт метрики байт в байт (adversary V3, mounts.go:57-62). Проверено сырыми запросами. Понижено: docs/security.md:14 объявляет метрики открытыми без узнавания. Задача — судить по EscapedPath() либо вынести /metrics на отдельный слушатель.
2. Своя реализация диапазонов вместо http.ServeContent (R2): ~110 строк семантики HTTP, которую стандартная библиотека делает сама.
3. Остановка закрывает базу под живой горутиной (C2 + O3, main.go:249-263, db.go:157-172): по истечении ForceShutdownTimeout run() возвращается, отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; замер — Close() вернулся за 2.8 мкс, пока другая горутина держала пишущую транзакцию, и та закоммитила после. Захват остаётся до 8 часов, следа нет.
4. Heartbeat воркера (законная половина O1): «конвейер встал» и «очередь пуста» неотличимы. Считать NoopJobError нельзя — инвариант.
5. Две строки ERROR на один транзиентный отказ шага (O2, воспроизведено дословным выводом). Вопрос записан в docs/review.md от 2026-08-10 и остаётся открытым.
6. sql.ErrNoRows не транслирован в доменную ошибку в трёх репозиториях (C6), при том что record_repo.go:139-141 в том же пакете правило исполняет.
7. cmd/devtools/resume.go не проверен ничем (S4): go test ./cmd/... даёт no test files, а тест воспроизводит тело подкоманды руками. Вынести тело из main-пакета в вызываемую функцию.
8. Развилка владельца по goose_db_version (S2): сузить требование и назвать таблицу учёта либо расширить сторож на всю применённую схему с поимённым исключением.
9. Запись решения ADR переписана на месте (S6): смена двух решений описана как уточнение формулировки. Это работа av-dev:doc-healthcheck.
10. Мелочь: имена копий <ULID><ext> не читаются без базы; defaultListLimit = 30 рядом с DefaultPageLimit = 30; RecordEventRepository.Append не заполняет event.CreatedAt; мёртвый довод prefix у selectList.
Выброшено как вкусовщина: ident.Timestamp/store.HasTemporary как экспортированная поверхность ради тестов; nullString/bytesReader; имя параметра copy против view; busy_timeout_ms как ключ с единицей измерения в имени; C5 (устаревший абзац logging.md:217-223) — работа сверки документов.
Проверено против «Типовые ложноположительные» docs/review.md:120-169: совпадение одно — молчание воркера на NoopJobError, снято из O1.
## 4. Promote candidates
- Правило сканера на rowToAudioRecord: перечень колонок чтения и перечень присвоений в сущность обязаны сверяться механически.
- Конвенция: значение, которым распоряжается спрашивающий, не идёт в журнал дословно ни под каким корнем. Сегодня logging.md:113 формулирует это только для запроса, отданного приложению, и находка V2 прошла в зазор.
## 5. Границы покрытия
Запускались specs, autotests, code, architecture, adversary, ops — все на метке large, режим по графу. basics не запускался: своих тем проекта нет. Разметчик review-scope в обычном виде не отрабатывал — метка задана владельцем, размер и сложность не мерились, обоснования разметки у этого прогона не существует. Корректор метки: сигнал от review-code, занижения нет; второго голоса нет.
Сработавшие потолки: architecture — 3, за срезом четыре подпороговых наблюдения и четыре «дешевле переделать»; code — конвенций 4/4, за срезом мёртвый довод prefix и незаполненный event.CreatedAt; specs, ops, autotests, adversary своих потолков не сообщили — сказать, сколько осталось за их срезом, нельзя; потолок триажа — 7 мест на 27 находок, за срез уехали V3, вторая половина R2, C2+O3, heartbeat, O2, C6, S4, S2, S6, все названы поимённо в урожае. Молча не выброшено ничего.
Чего проходы не могли проверить: adversary не поднимал настоящую Authelia и настоящий обратный прокси — весь барьер входа держится им, браузера в прогоне нет; ops не имел реального профиля нагрузки; autotests судил покрытие, а не способность теста упасть — мутационная сверка в проекте запрещена.
Осталось на человеке (docs/review.md:289-335): поведение SpeechKit и Object Storage под нагрузкой; реальный профиль нагрузки; стойкость ffmpeg к вредоносному входу; поведение настоящей Authelia и правило обратного прокси — и это прямо задевает две находки: обход X-Forwarded-For вменяется прокси в чужом репозитории, а лечится здесь, а достижимость /%6detrics зависит от правила прокси, которого отсюда не видно; поведение браузера с куками. Перестали проверять сознательно: разбор вывода настоящего ffprobe; работа с настоящими внешними собеседниками.
Четыре строки, которых не принёс ни один проход: решения проекта не сверялись (docs/adr/ процессный, расхождение ловит doc-healthcheck); записанные наблюдения не использовались (docs/research/ не открывался, каждое число снято на этом прогоне); поимённая сверка с руководствами по стилю Go не задавалась; альтернативной реализации, с которой можно сдиффить решения, у конвейера нет — «не знаю, чего не знаю» на изменении, переносящем всё хранилище, не достаёт никто.
Поразрядная деградация одна и своя: инвариант про колонки записи существует, но потерял предмет, поэтому сослаться на него как на оракул было нельзя — вынесено отдельной находкой.
@@ -0,0 +1,389 @@
## MODIFIED Requirements
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
которым назван пришедший, ни адрес почты пользователя.
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла на диске: там
строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно
назваться, чтобы стать этим человеком.
Короткий токен файла из перечня ушёл вместе с самим токеном: значений на
предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
доступа сам по себе не даёт и без него путь запроса не прослеживается.
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** приходит первое обращение и учётная запись заводится
- **THEN** адрес почты не встречается ни в одной журнальной записи
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни запросом к базе: колонка владельца пустого значения не принимает,
и норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и
к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён»
превращает чтение в перебор: по разнице ответов считывается, какие записи
заведены, а идентификатор записи и есть то, что разграничение прячет. Каким
именно ответом это выражено, нормирует capability `archive`: там живут адреса
чтения записи, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает:
спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и
отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы
намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает
**спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек узнан и принял запись
- **WHEN** он спрашивает карточку этой записи
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним узнанным
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Чужой файл неотличим от несуществующего
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится узнанный предъявитель
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** база её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Область узнавания — корень приложения
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
### Requirement: Учётная запись заводится первым обращением
Сервис SHALL заводить учётную запись при первом обращении с новым значением
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
таблицы пользователей.
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
пределу колонки и чистится от управляющих знаков, негодный адрес почты
отбрасывается. Негодное значение необязательного поля MUST не отменять
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное
обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а
правка имени у провайдера меняла бы карточку человека молча, посреди его работы.
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
с чужим адресом досталось бы чужой записи.
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
его будет нечем — владелец записи назначается один раз и не меняется. Держится
это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет
вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей
запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к
базе, и это работа владельца сервиса, а не спрашивающего.
Одновременные первые обращения одним значением MUST кончаться одной учётной
записью: уникальность держит схема, а не порядок обращений.
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
заведением записи **без почты**: она необязательна. Без этого разреза второй
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
по имени снова ничего не находит.
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
которому провайдер выдал логин ушедшего, при первом обращении попадает в
существующую запись и получает весь её архив. Не допускать переиспользования —
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
цена принимается, а не обходится.
#### Scenario: Первое обращение заводит запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит запрос с заголовком `Remote-User`
- **THEN** учётная запись появляется
- **AND** запрос идёт от её имени
#### Scenario: Повторное обращение попадает в ту же запись
- **GIVEN** учётная запись заведена первым обращением
- **WHEN** приходит второй запрос с тем же значением заголовка
- **THEN** новой учётной записи не появляется
- **AND** запрос идёт от имени прежней
#### Scenario: Разным значениям — разные записи
- **WHEN** приходят запросы с двумя разными значениями заголовка
- **THEN** заводятся две учётные записи
- **AND** записи одного не видны другому
#### Scenario: Имя не переписывается вторым обращением
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
- **THEN** имя учётной записи остаётся прежним
#### Scenario: Два одновременных первых обращения дают одну запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** два запроса с одним значением заголовка приходят одновременно
- **THEN** в таблице пользователей появляется ровно одна строка
- **AND** оба запроса идут от её имени
#### Scenario: Занятая почта не мешает завести запись
- **GIVEN** учётная запись с этим адресом почты уже заведена
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
`Remote-Email`
- **THEN** заводится своя учётная запись
- **AND** адреса почты у неё нет
#### Scenario: Адреса правки учётной записи у сервиса нет
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись
- **THEN** такого адреса нет
#### Scenario: Негодное имя не отменяет заведения
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с именем длиннее предела колонки
- **THEN** учётная запись заводится, а имя обрезано по пределу
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
- **THEN** учётная запись заводится без почты
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
- **GIVEN** бюджет ограничителя частоты выбран
- **WHEN** приходит обращение с новым значением заголовка
- **THEN** ответ несёт отказ ограничителя
- **AND** учётной записи не появляется
#### Scenario: Заведение учётной записи видно в журнале
- **WHEN** приходит первое обращение с новым значением заголовка
- **THEN** журнал несёт строку о заведении с идентификатором записи
- **AND** значения заголовка в ней нет
## REMOVED Requirements
### Requirement: Иных способов открыть сессию нет
**Reason**: Требование выключало собственные входы встроенного хранилища —
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
коду, обмен кода у внешнего провайдера, восстановление доступа — и закрывало
правилами доступа правку этой коллекции. Хранилище уходит из проекта целиком:
ни одного из этих адресов не существует, и выключать больше нечего.
**Migration**: Единственный способ представиться остаётся прежним — заголовок
доверенного источника на каждом запросе, требование «Пришедшего называет
доверенный источник». Что учётную запись заводит только код сервиса и что её ключ
не правится снаружи, нормирует требование «Учётная запись заводится первым
обращением»: адреса правки у сервиса нет вовсе.
@@ -0,0 +1,335 @@
## ADDED Requirements
### Requirement: Файл записи отдаётся адресом приложения
Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET
/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную
спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что
успел записать конвейер, а не состояния записи.
**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со
значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным
контрактом молча.
**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:**
- `original` — файл, принятый от отправителя;
- `normalized` — копия, приведённая к рабочему формату.
Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же
кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как
пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек
увидел бы «не найдено» на своей записи, загруженной минуту назад.
Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному
вводу — но только у **своей** записи.
Порядок проверок MUST быть один: владение записью судится **до** разбора значения
копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей
записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и
телом. Неотличимость чужой записи от несуществующей главнее формы ответа на
негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на
чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой
разнице перебирался бы список заведённых записей одним негодным параметром.
Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям:
запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает
её запросом диапазона, а не повторной загрузкой целиком.
**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же
формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом
библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково:
неудовлетворимый (начало за концом файла) и множественный (в запросе назван
больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких
частей — это отдельный тип содержимого со своими границами, а просит его один
только самодельный запрос, потому что проигрыватель в браузере шлёт один
диапазон.
Причина у требования общая с прочими отказами, рождающимися не в обработчике:
форма тела на адресах приложения одна, и код отказа принадлежит закрытому
перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и
`message`, и приложение разбирает его отдельной веткой — единственной такой на
все адреса.
Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же,
чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу
даёт узнавание, а не выданное значение, нормирует capability `storage`.
Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при
сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи.
**Перечня доступных копий карточка записи не объявляет** — до задачи об экране
прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий
узнаёт отказом состояния на самом обращении за файлом.
Довод, которым перечень доступных видов текста объявляется карточкой всегда, к
копиям файла не относится, и это разные случаи. Видов текста несколько, шаг
завершения пишет их несколькими операциями, поэтому состояние «сплошной текст
есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение
обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и
каждая выводится из рубежа записи, который карточка несёт и так: принятая копия
есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение.
Второе поле повторяло бы рубеж и разошлось бы с ним молча.
Перечень появится тогда, когда у него появится потребитель: экран прослушивания
приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять
контракт, которого никто не разбирает.
#### Scenario: Владелец забирает принятую копию
- **GIVEN** запись принята
- **WHEN** владелец просит её файл копией `original`
- **THEN** ответ несёт содержимое принятого файла и его длину
#### Scenario: Приведённой копии ещё нет
- **GIVEN** запись принята и не дошла до приведения
- **WHEN** владелец просит её файл копией `normalized`
- **THEN** ответ имеет код `409`
- **AND** он отличается от ответа на неизвестный идентификатор
#### Scenario: Копия неизвестна или не названа
- **WHEN** владелец просит файл копией, которой сервис не знает, либо не
называет копии вовсе
- **THEN** ответ имеет код `400`
#### Scenario: Чужой файл неотличим от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный просит её файл копией, которой сервис не знает
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи
#### Scenario: Перечня копий в карточке нет
- **GIVEN** запись принята и приведена к рабочему формату
- **WHEN** владелец спрашивает её карточку
- **THEN** поля с перечнем доступных копий файла в ответе нет
#### Scenario: Проигрыватель просит кусок записи
- **GIVEN** запись принята
- **WHEN** владелец просит её файл с указанием диапазона
- **THEN** ответ несёт запрошенный кусок, а не файл целиком
#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом
- **GIVEN** запись принята, и её файл короче запрошенного начала
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range:
bytes=99999999-`
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт
- **GIVEN** запись принята
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком,
называющим два диапазона
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
- **AND** ответа из нескольких частей сервис не отдаёт
## MODIFIED Requirements
### Requirement: Адреса приложения живут своим пространством
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
всякий неизвестный путь, — норму держит capability `webapp`.
Корень приложения остаётся прежним, и это решение подтверждается, а не
принимается заново: формы запросов и ответов приложения смена хранилища не
трогает ни одним полем.
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
обновления, вправе занять новое имя рядом с нашим, больше нет.
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
включение нашего правила вводило в действие и чужие правила на чужих адресах;
платить за это больше нечем — чужих адресов нет.
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
MUST не влиять на ключ бюджета ничем.
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
падать обратно на адрес пира.
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
запрос, и меняет он его на каждом запросе.
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
оба ломает либо барьер, либо бюджет.
#### Scenario: Адрес приложения отвечает под своим корнем
- **GIVEN** человек узнан
- **WHEN** он спрашивает список своих записей под корнем приложения
- **THEN** ответ приходит от сервиса
#### Scenario: Пространства хранилища не существует
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Адреса панели не существует
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный его кодом
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Ограничитель частоты покрывает адреса приложения
- **GIVEN** сервис поднялся
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
- **THEN** лишние получают отказ ограничителя
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
- **THEN** бюджет каждого считается отдельно
- **AND** исчерпание бюджета одним не отказывает другому
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
- **GIVEN** запросы приходят с адреса вне перечня доверенных
- **WHEN** они несут заголовок пересылки с разными значениями адреса
- **THEN** бюджет у них общий и считается по адресу пира
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
спрашивающего, а справа — адрес, приписанный прокси
- **THEN** бюджет считается по правому значению
- **AND** запросы чаще бюджета получают отказ ограничителя
#### Scenario: Цепочка читается всеми строками заголовка
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
- **THEN** ключ бюджета берётся из последней строки, а не из первой
### Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы, негодный диапазон в запросе файла;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста или копии файла
запрошенного вида у записи ещё нет;
- отказ базы и всякая неназванная причина — `500`.
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
вместе со своим единственным случаем: им был владелец панели, предъявивший
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
в коде мёртвой.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
`too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
адресах приложения две, а самый частый отказ у человека на мобильной сети —
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
числом.
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой базы виден как сбой
- **GIVEN** база отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
#### Scenario: Негодная запись видна как негодная
- **GIVEN** источник метаданных не может прочитать присланную запись
- **WHEN** отправитель шлёт её приёмом
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
недоступному состоянию и по сбою базы
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
@@ -0,0 +1,128 @@
## 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`.
Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом
до того, как запись попадёт в память, а схема отвечала бы отказом сохранения
после укладки файла.
Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание
заводит учётную запись само, а предъявителя с собственным токеном хранилища не
существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим
единственным случаем.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в каталоге данных одним файлом
- **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`, чтобы у
файла на диске расширение было всегда.
Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой
библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и
правило перестало быть отменой чужого поведения — оно стало прямым описанием
своего.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла на диске оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла на диске оканчивается на `.audio`
#### Scenario: Имя отправителя в имя файла не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла на диске не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
@@ -0,0 +1,255 @@
## MODIFIED Requirements
### Requirement: Захват задачи неделим
Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор
подходящей записи и пометка её захваченной MUST происходить вместе, одним
оператором с возвратом.
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
сохранение писало бы этот ноль поверх сохранённого значения.
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки подкомандой оснастки, — обязан обращать запись первого в
отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись по очереди, испортив её результат.
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
получить признак «работы сейчас нет».
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
при самом захвате.
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За работой пришли трое разом
- **GIVEN** к работе пригодна ровно одна запись
- **WHEN** три захвата идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы сейчас нет»
#### Scenario: Захваченная запись не выдаётся второй раз
- **GIVEN** запись захвачена и срок захвата не истёк
- **WHEN** приходит следующий захват
- **THEN** эта запись ему не выдаётся
#### Scenario: Захват отдаёт идентификатор и свой признак
- **GIVEN** к работе пригодна запись
- **WHEN** воркер её захватывает
- **THEN** захват возвращает идентификатор записи и признак этого захвата
- **AND** колонки записи шаг читает отдельным чтением
#### Scenario: Признак перевыданного захвата отличается от прежнего
- **GIVEN** запись захвачена, и признак первого захвата известен
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
- **THEN** признак нового захвата отличается от признака первого
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата**
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
результата.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет запись, продолжая работать. Снять захват может и человек, вернувший
остановленную запись в работу. Без условия по уникальному признаку два воркера
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
владелец.
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
пишущими остаётся его предметом целиком.
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и
без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой
оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она
написана про поля, а не про того, чьей рукой правка сделана, и переживёт
появление экранов.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** за это время та же запись досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
#### Scenario: Человек снял остановку под работающим шагом
- **GIVEN** шаг работает над захваченной записью
- **AND** человек за это время снял с неё признак остановки, освободив захват
- **AND** запись досталась другому воркеру
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
### Requirement: Остановка записи — признак, а не рубеж
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**:
- признак остановки — время остановки, причину и машинный текст отказа;
- признак захвата и срок его протухания;
- число отказов;
- паузу перед повтором;
- время входа в рубеж.
Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни
отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до
протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по
прежнему значению. Оставленное время входа в рубеж останавливает запись снова
первым же захватом, если остановленной она простояла дольше предела, и перезапуск
не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на
накопленный срок.
Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись,
а поля выше сбрасывает домен одним действием. Правка колонок мимо домена
повторяет перечень вторым местом, и второе место расходится с первым молча.
Возврат в работу MUST писать событие журнала записи с происхождением «человек».
Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в
журнале от записи, которую конвейер вёл без остановок, а происхождение события
перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не
остаётся.
Инструментом возврата сегодня служит подкоманда набора инструментов
разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи.
Норма написана про поля и про домен, а не про инструмент, и появление экрана её
не трогает.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
невозможным.
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
переставшая браться в работу, обязана нести признак остановки с причиной.
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
без следа.
Остановленная запись MUST не выдаваться захвату.
#### Scenario: Остановленная запись продолжает с места остановки
- **GIVEN** шаг остановил запись на рубеже приведения
- **WHEN** признак остановки снимают
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
#### Scenario: Остановленная запись не выдаётся захвату
- **GIVEN** у записи стоит признак остановки
- **WHEN** за её рубежом приходит захват
- **THEN** запись ему не выдаётся
#### Scenario: Снятие признака сбрасывает всех сторожей
- **GIVEN** запись остановлена с накопленными отказами и паузой
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** признак остановки снимают
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
#### 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,56 @@
## MODIFIED Requirements
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой
строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
репозиторий читает строку целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит,
нормирует capability `storage`, требование «Файл записи живёт в хранилище»:
третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при
этом не считается — их у записи по-прежнему две.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**:
адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а
всякий адрес, отдающий содержимое записи, MUST судить владельца связанной
аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило
просмотра коллекции, которыми закрытость держалась прежде, — механизмы
встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала
зависеть от настройки, которую мы не писали.
Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку
метрики, ни в ответ отправителю.
Норму держит capability `storage`, требование «Содержимое записи закрыто везде,
где лежит»; здесь она названа потому, что попытка распознавания — то место, куда
содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
- **GIVEN** распознавание завершилось и ответ провайдера получен
- **WHEN** запись доходит до конечного рубежа
- **THEN** сохранённый ответ доступен по строке попытки целиком
#### Scenario: Опрос не тянет сохранённый ответ
- **GIVEN** у попытки распознавания есть сохранённый ответ
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
#### Scenario: Адреса чтения сохранённого ответа у сервиса нет
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой
записи
- **THEN** такого адреса у сервиса нет
- **AND** содержимого он не получает
@@ -0,0 +1,743 @@
## ADDED Requirements
### Requirement: База принимает одного писателя
Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести
отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное
соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST
задаваться числом, а не оставаться умолчанием драйвера.
Все три настройки MUST задаваться **строкой подключения обоих пулов** — и
пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение
внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она
выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт
соединения и заводит новые по мере надобности, поэтому запрос, выполненный один
раз после открытия, настраивает одно соединение из многих, а остальные остаются с
умолчанием — молча. На включённых внешних ключах держатся требования «Учётная
запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл
без владельца не сохраняется»: с выключенными все три зеленеют на том соединении,
где настройку успели поставить, и не работают на соседнем.
Требование заводится потому, что эту настройку прежде держала за нас чужая
библиотека двумя пулами. Драйвер пишет единственным соединением: несколько
воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и
получают его на записи результата шага, то есть после оплаченной работы.
**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем
соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция,
начатая на читающем соединении и позже пытающаяся писать, получает отказ по
занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite
не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под
правило подпадают захват записи и накат шага схемы: каждый читает состояние,
которое сам же меняет.
**Узнавание под правило не подпадает, и устроено оно двумя соединениями.**
Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая
транзакция MUST открываться только тогда, когда запись не нашлась. Слой
узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона
при проигрывании, — а заводится учётная запись один раз за жизнь человека:
пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к
единственному пишущему соединению. Очередь эта ожиданием занятой базы не
ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд
ожидания видны только замером.
Окно между двумя соединениями MUST закрываться **повторным поиском внутри
транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо
взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не
порядок обращений.
Значения ожидания и числа соединений MUST жить там, где проект держит числовые
настройки, и MUST не повторяться второй константой рядом.
#### Scenario: Несколько воркеров пишут разом
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** все они дошли до записи своего результата одновременно
- **WHEN** результаты записываются
- **THEN** каждый записан, и ни один не отказал по занятости базы
#### Scenario: Настройки базы применены при подъёме
- **WHEN** сервис поднялся на чистом каталоге данных
- **THEN** у базы включён журнал упреждающей записи
- **AND** ожидание занятой базы равно объявленному числу
#### Scenario: Внешние ключи включены на соединении читающего пула
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA
foreign_keys`
- **THEN** ответ — `1`
#### Scenario: Узнавание известного не ждёт писателя
- **GIVEN** учётная запись с этим логином уже заведена
- **AND** пишущее соединение занято открытой транзакцией
- **WHEN** приходит следующее обращение тем же логином
- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя
- **AND** второй учётной записи не заведено
#### Scenario: Составная операция не отказывает по занятости
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** каждый выполняет операцию, которая читает состояние записи и следом его
пишет
- **WHEN** операции идут одновременно
- **THEN** каждая завершена, и ни одна не отказала по занятости базы
### Requirement: Время и идентификаторы приходят из одного места
Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в
RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`.
Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет
только сам сервис.
Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
совпадает с хронологией, и отбор по колонке времени работает без разбора
значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
положили, а отбор захвата сравнивает строки — колонка, заполненная то одним
видом, то другим, обращает условие срока протухания в постоянную истину или ложь
молча, и запись не выдаётся ни одному воркеру никогда.
**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой
точки чтения времени, которую держит линтер проекта. Умолчаний вида
`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам:
умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка,
забывшая проставить время, при умолчании проходит молча, а без него падает
громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим
форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся
одно.
Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и
ставить его MUST приложение единой точкой при заведении строки. Это то, что
конвенция проекта объявляет нормой; расхождение, при котором идентификаторы
выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним.
Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет
вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability
`archive`.
#### Scenario: Вид времени один на все колонки
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** смотрят колонки времени в применённой схеме
- **THEN** все они объявлены одним типом и несут время одним видом
- **AND** умолчания времени ни у одной из них нет
#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково
- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками
- **AND** обе стоят на одном рубеже и пригодны к захвату
- **WHEN** воркеры разбирают очередь
- **THEN** захвату выдаются обе
- **AND** ни одна не остаётся в очереди навсегда
### Requirement: Содержимое записи закрыто везде, где лежит
Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её
строки перечисляются или читаются мимо проверки владельца связанной записи.
Требование распространяется на все приложения записи — тексты, структуру реплик,
попытку распознавания с её сохранённым ответом, журнал событий и темы — и
заводится потому, что содержимое лежит не в одной строке, а в нескольких.
Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы
как снятое.
Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт
наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит,
нормирует требование «Файл записи живёт в хранилище»: третьим файлом в
подкаталоге записи.
Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в
журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это
нормировано для файла записи.
Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где
лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость
держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит
владельца.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи
- **THEN** содержимого он не получает
#### Scenario: Неузнанному содержимое не отдаётся
- **WHEN** содержимое записи запрашивают неузнанным
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисления приложений записи не существует
- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток
распознавания
- **THEN** такого адреса у сервиса нет
## MODIFIED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой
базой, а не порядком файлов на диске.
Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс,
оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а
следующий запуск применяет его второй раз — и второе применение отказывает на
заведённой таблице, роняя старт на шаге, который на самом деле цел.
Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй
процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо
отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём
бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два
наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не
описывает ни один шаг.
Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а
не из порядка чтения каталога: порядка обхода файловая система не обещает, а
разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз.
Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.
**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага
MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом
на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их
становятся сотни, и первопричина в них теряется.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежних записей
#### Scenario: Схема накатана до первой строки о готовности
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** до строки журнала о готовности схема приведена целиком
- **AND** ни одного отказа в журнале до неё нет
#### Scenario: Старт, оборванный между шагом и отметкой о нём
- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём
- **WHEN** сервис запускается снова
- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий
шаг
- **AND** шаг не применяется второй раз
#### Scenario: Отказ шага схемы роняет старт
- **GIVEN** шаг схемы не применяется
- **WHEN** сервис запускается
- **THEN** старт кончается отказом, называющим шаг
- **AND** ни один вход не поднят
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого
каталога MUST задавать он сам. Файл MUST адресоваться записью, которой
принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает
его, ничего не зная о раскладке.
Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и
MUST давать убрать запись целиком одним движением, не перебирая имена по маске.
Плоского каталога, где копии различаются приставкой в имени, MUST не
заводиться.
Содержимое записи MUST не читаться в память целиком ни при укладке, ни при
чтении: расчётный потолок записи — шесть часов, и такая запись в память не
помещается.
**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что
переименование в его пределах не копирует содержимое и не может оборваться на
середине.
Порядок MUST быть один: строка о файле заводится **после** того, как содержимое
лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку,
указывающую на файл, которого ещё нет или который короче принятого.
Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
записи со строкой файла не сверяются — так требует «Аудиозапись — центральная
сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в
конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый
результат. Атомарная укладка — единственное, что этого не допускает.
Отсюда две нормы о неудачах:
- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST
быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, —
штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
принятой копии это мусор, на который не ссылается ничто и о котором узнать
неоткуда;
- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ
источника: временного имени не остаётся, рабочего имени не появляется, строки о
файле нет. Записи, наполовину принятой, человек не видит.
Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же
подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки
он ехал бы в память при каждом опросе готовности — этого capability `recognition`
избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого
и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под
ту же атомарную укладку и под ту же уборку записи одним движением, что и копии
аудио.
Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи
по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис
отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ
читают снаружи, у сервиса нет вовсе.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела
запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь
не «без предела», а величины на два-три порядка меньше нужного, и оставленные как
есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая
запись исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в каталог данных
- **WHEN** запись принята
- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью
- **AND** второго каталога записей рядом не появляется
#### Scenario: Копии одной записи лежат вместе
- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание
- **WHEN** смотрят, где лежат её файлы
- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат
под идентификатором этой записи
- **AND** второго места, где лежит что-то из них, нет
#### Scenario: Источник оборвался посреди потока
- **GIVEN** отправитель шлёт запись и обрывает поток на середине
- **WHEN** укладка отказывает
- **THEN** строки о файле не заведено
- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи
не остаётся
#### Scenario: Запись длиннее умолчания принимается
- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути
приёма
- **THEN** она ложится в каталог данных, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца
сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.
Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого
токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и
владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит
до файла сразу, а не через срок жизни выданного значения.
Обращение к файлу чужой записи MUST быть неотличимо от обращения к
несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы
чтение в перебор заведённых записей.
Каким адресом файл уходит и как называется вид копии, нормирует capability
`archive`: там живут адреса приложения, и держатель нормы обязан быть один.
Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не
по адресу приложения.
**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в
метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из
журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку
не убрать.
Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не
называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее
хранилище MUST не называть полного адреса объекта. И то и другое кончается в
журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Владелец забирает свой файл
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** владелец записи просит её файл
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** файл просят неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Значения на предъявителя сервис не выдаёт
- **GIVEN** человек узнан
- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл
- **THEN** такого адреса у сервиса нет
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из каталога данных и шаг проходит
#### Scenario: Файл записи, которой нет
- **WHEN** просят файл записи с неизвестным идентификатором
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу путь к файлу не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в каталог данных, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается с диска
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь
объявлена внешним ключом на учётную запись и обязательна. Пока обязательность
жила в одном приёме, ничью запись заводили руками мимо него, она уходила в
конвейер, стоила денег на распознавание и не доставалась потом никому.
Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у
него нет. Обязательность от этого не отменяется — она перестала зависеть от того,
кто пишет, и стала свойством схемы.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или запросом к базе
- **THEN** база её не сохраняет
#### Scenario: Владелец, которого нет, не принимается
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни
одна учётная запись
- **THEN** база её не сохраняет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — а отдача файла MUST пускать к нему только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Разное
правило у записи и у её файла читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по
которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный просит файл этой записи
- **THEN** содержимого он не получает
- **AND** ответ тот же, что и на неизвестный идентификатор записи
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он просит файл своей записи
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** база его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой
схемой — обязательной связью, которая не даёт убрать строку, пока на неё
ссылаются.
Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним
местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте,
пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не
существует.
Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления
может стать больше одного, а проверка, записанная у одного, у остальных читалась
бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе —
его приносит задача про удаление записи. До неё удаление учётной записи с
записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым
учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление
запросом к базе.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её строку удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её строку удаляют
- **THEN** удаление проходит
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней
приложено, — отдельными строками со ссылками на запись. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
Тем же доводом запись MUST нести своими колонками **имя файла, данное
отправителем, длительность и размер**. Все три показываются в списке. Приём
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
вместе с записью.
**Имена колонок и единицы измерения нормативны:** `original_filename`,
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные
которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в
этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
записи он не принадлежит.
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас».
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
#### Scenario: Длительность и размер читаются без строки файла
- **GIVEN** запись принята
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Пустая длительность в схему не ложится
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым
размером
- **THEN** база её не сохраняет
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Отдельной таблицей, а не набором строк в записи, — потому что перечень тем
человека нужен целиком перед каждым обращением к модели, а собрать его из
наборов строк можно только перебором всех его записей.
Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт
два десятка тем, и словарь распухает за неделю. То же число сервис объявляет
приложению — норму держит capability `archive`, — и второй константы рядом MUST
не заводиться.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются
раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
- **WHEN** смотрят словарь тем
- **THEN** это две разные темы, каждая своего владельца
#### Scenario: Шестая тема не заводится
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит
## REMOVED Requirements
### Requirement: Наружу хранилище отдаёт только то, что заказано
**Reason**: Требование закрывало собственную поверхность встроенного хранилища —
перечисление записей коллекций, журналы запросов, резервные копии, настройки,
расписание, — которую тот публиковал тем же портом. Хранилище уходит из проекта
целиком, и поверхности этой не существует: закрывать больше нечего.
**Migration**: Отвечает сервис теперь только своими адресами, а всё, что не
принадлежит корню приложения и не является отдельным адресом наблюдения, идёт
общим правилом неизвестного пути — норму держит capability `webapp`. Что
содержимое записи закрыто везде, где лежит, нормирует требование «Содержимое
записи закрыто везде, где лежит».
### Requirement: Владелец видит записи в панели
**Reason**: Панель администратора уходит вместе с хранилищем и **не заменяется
ничем** — решение владельца 2026-08-22. Требовать поведения от инструмента,
которого нет, нельзя.
**Migration**: Возврат остановленной записи в работу делает подкоманда набора
инструментов разработчика — норму держит capability `pipeline`, требование
«Остановка записи — признак, а не рубеж»: там названы и поля, которые возврат
обязан сбросить, и обязанность писать событие журнала записи с происхождением
«человек». Прочая правка записи ждёт экранов владельца, а их приносят задачи
`audiorecord-actions` и `play-recording-in-app`.
### Requirement: Пароль владельца от панели не лежит в конфигурации
**Reason**: Пароля от панели больше нет — вместе с панелью. Секрет, появившийся
только ради перевода на встроенное хранилище, пропадает, и ключа под него в
конфигурации не заводится по той простой причине, что заводить нечего.
**Migration**: Инвариант проекта «Секрет не покидает конфиг» остаётся в силе для
оставшихся секретов — ключа распознавания и пары ключей внешнего хранилища.
Приглашения завести владельца сервис больше не печатает.
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
**Reason**: Требование написано словарём коллекций, правил доступа и защищённых
полей — механизмов встроенного хранилища. Механизмов этих не остаётся, а норма
остаётся: она переписана требованием «Содержимое записи закрыто везде, где
лежит».
**Migration**: Читать норму по новому требованию. Закрытость держится тем, что
адреса чтения содержимого пишет сервис и каждый судит владельца связанной
записи, а не правилом просмотра коллекции и не пометкой поля.
@@ -0,0 +1,178 @@
## MODIFIED Requirements
### Requirement: Неизвестный путь вне корней открывает приложение
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у
сервиса остался **один**`/app` у приложения; отдельными адресами стоят
`/health` и `/metrics`.
Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его
собственным пространством и панели администратора у сервиса не осталось, и
адресов под этими именами не существует. Прежние пути хранилища и панели поэтому
отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит,
ничем не отличается от любого другого свободного имени, а второй перечень
«когда-то занятых корней» разошёлся бы с первым молча.
Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака,
раскодируется в тот же путь и попадает в то же правило — правило одно, и особого
случая у него нет.
Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
`/app` не достался бы никому и уехал бы разметкой.
Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения,
адресом приложения при этом не является и потому MUST отвечать как **неизвестный
путь под корнем приложения** — узнанному `404` телом отказа приложения,
неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем.
Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка
приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая
же сборка.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
приняла бы её за ответ.
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
отвечать `405`.
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
- **GIVEN** приложение открыто на своём маршруте
- **WHEN** браузер спрашивает этот путь заново
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Голый корень разметкой не подменяется
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без
косой черты
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем
- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения
точно и без косой черты
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
отделён от него косой чертой
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний путь хранилища открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес панели открывает приложение
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный кодом этого знака
- **THEN** оба ответа имеют код `200`
- **AND** тело каждого — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
### Requirement: Путь, отданный приложению, в журнал не идёт
Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути,
а самого запрошенного пути MUST не записывать ни в один свой журнал. То же
относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса
наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них
не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного
приложению, к строке добавляется **исход из закрытого перечня** — разметка,
ресурс, отказ.
Правило MUST накрывать обе половины адресного пространства — и путь вне корней
сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не
меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но
множеством значений под корнем распоряжается спрашивающий ровно так же, как и
вне его.
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
перечню известных.
Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и
пишет он прочитанный идентификатор, а не тот, что стоял в запросе.
Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком
вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а
перестало зависеть от настройки чужого журнала, которую мы не писали.
#### Scenario: Путь не доезжает до журнала
- **WHEN** приходит запрос на путь вне корней сервиса
- **THEN** записи о нём не несут этого пути
- **AND** несут исход и длину пути
#### Scenario: Длинный путь журнал не наполняет
- **WHEN** приходит запрос на путь длиной в тысячу знаков
- **THEN** записи о нём не растут вместе с длиной пути
#### Scenario: Путь под корнем приложения журнал не пишет
- **GIVEN** пришедший не узнан
- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному
объявленному образцу адреса
- **THEN** записи о нём не несут этого пути
- **AND** не растут вместе с его длиной
#### Scenario: Объявленный образец адреса приложения в журнале различим
- **WHEN** приходит запрос на объявленный адрес приложения
- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь
#### Scenario: Второго журнала у сервиса нет
- **GIVEN** сервис поднялся
- **WHEN** приходит запрос на путь вне корней сервиса
- **THEN** запись о нём появляется только в журнале сервиса
@@ -0,0 +1,230 @@
## Критерии приёмки
Скопированы из записи задачи `storage-without-pocketbase` дословно: запись
закрытие удалит, критерии обязаны её пережить.
- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase`
даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`.
- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос
к файлу чужой записи и к несуществующей отвечает одинаково.
- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами
под `-race` — запись достаётся ровно одному, второй получает отказ по значению
признака захвата.
- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`,
`/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь.
- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на
чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности.
Ниже — рубрика ревью дизайна. Приёмка судится по одному списку, поэтому она
стоит здесь же, а не отдельным документом.
- Ограничения схемы действуют при любом способе записи и на каждом соединении
пула. **Оракул:** соединение, взятое из читающего пула, отвечает `1` на `PRAGMA
foreign_keys`; вставка аудиозаписи с несуществующим владельцем отвергается базой
и на пишущем соединении, и на читающем.
- Захват неделим, а держатель узнаётся значением признака. **Оракул:** тест под
`-race` с несколькими воркерами — запись достаётся ровно одному; запись
результата с чужим значением признака не проходит и записи не меняет.
- Узел, читающий состояние, которое сам же меняет, называет неделимый шаг.
**Оракул:** захват записи, заведение учётной записи первым обращением и накат
шага схемы идут одним запросом либо одной транзакцией на пишущем соединении;
тест на два одновременных первых обращения даёт ровно одну строку пользователя.
- Границы транзакции названы, и отмена контекста оставляет запись либо прежней,
либо полной. **Оракул:** тест с отменой контекста посреди составной операции —
читающий следом видит либо всё прежнее, либо всё новое, и ни одной половины.
- Накат схемы идемпотентен и не оставляет полуприменённого состояния.
**Оракул:** второй запуск на заведённом каталоге не применяет ни одного шага;
запуск, оборванный между применением шага и отметкой о нём, при повторе даёт тот
же исход либо отказ с именем шага, но не удвоенное применение.
- Укладка файла атомарна, и неполная укладка не выдаёт себя за полную.
**Оракул:** источник, отдающий отказ на середине потока, не оставляет ни строки
о файле, ни файла под рабочим именем, ни временного имени в подкаталоге записи.
- Время, идентификаторы и их вид приходят из одного места. **Оракул:** все колонки
времени применённой схемы объявлены одним типом и без умолчания; строка,
заведённая приёмом, и строка, заведённая запросом к базе, попадают в отбор
захвата одинаково.
- Отдача файла одинаково отвечает на чужое и несуществующее, а размер ответа
ограничен запрошенным. **Оракул:** тест обработчика — чужая запись,
несуществующая запись и негодное значение параметра `copy` у чужой записи дают
один код и одно тело; запрос с диапазоном отдаёт длину запрошенного куска, а не
файла целиком; настоящий HTTP-запрос с `Range: bytes=99999999-` и запрос с двумя
диапазонами дают код `400` и тело с полями `error_code` и `message`, а не `416`
телом библиотеки.
- Отказы, рождающиеся не в обработчике, приходят той же формой, что и отказы
обработчика. **Оракул:** настоящий HTTP-запрос через поднятую цепочку слоёв —
предел тела, ограничитель частоты и неизвестный путь под корнем приложения дают
тело с полями `error_code` и `message` и код из закрытого перечня; вызовом
отображателя ошибки это не проверяется.
- Ключ ограничителя частоты называет того, кого надо ограничить. **Оракул:** два
клиентских адреса через один доверенный прокси расходуют разные бюджеты, а
заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
влияет.
- Горячие выборки опираются на индекс. **Оракул:** `EXPLAIN QUERY PLAN` отбора
захвата и `EXPLAIN QUERY PLAN` списка, сужаемого владельцем и страницей, не
показывают полного сканирования таблицы аудиозаписей.
- Подъём и остановка симметричны и громкие. **Оракул:** отказ шага подъёма даёт
ненулевой код выхода и ровно одну строку журнала о причине; мягкая остановка
закрывает то же, что открыл подъём, — оба пула базы, входы и воркеров, — и
повторная остановка не даёт паники.
## 1. База и её схема
- [x] 1.1 Завести подключение к базе на `modernc.org/sqlite`: одно соединение для
записи, отдельный пул для чтения, журнал упреждающей записи, ожидание
занятой базы числом из настроек
- [x] 1.2 Подключить `github.com/pressly/goose/v3` библиотекой: добавить модуль в
`go.mod`, завести каталог шагов, вшитый в бинарник, и поднимать провайдер в
точке входа. Командная строка `goose` не заводится: ни отдельного
исполняемого файла, ни своего шага выкладки
- [x] 1.3 Взять исключающую блокировку наката самим: `goose` под SQLite её не
поставляет — запиратели у него только для PostgreSQL. Замок на файле в
каталоге данных (`syscall.Flock`, `LOCK_EX`) берётся до наката и снимается
после; проверить тестом, что второй накат на том же каталоге ждёт либо
отказывает, а шаги параллельно не применяются
- [x] 1.4 Удалить каталог шагов PocketBase целиком —
`internal/adapter/repo/pocketbase/migrations`
- [x] 1.5 Завести один шаг начальной схемы в новом каталоге шагов: разовое снятие
инварианта «применённая миграция не переписывается» решением владельца
2026-08-22
- [x] 1.6 Перевести ключ `[docs] migrations` в `.av-dev.toml` на новый каталог
шагов. Без этого шаг гейта `migrations` покраснеет на удалении файлов
прежнего каталога — он читает их статусом `D` как переписанный применённый
шаг
- [x] 1.7 Написать шаги схемы под все таблицы — учётные записи, аудиозаписи,
файлы, тексты, структура реплик, попытки распознавания, журнал событий,
темы — с обязательными связями владельца и обязательными колонками
`original_filename`, `duration_ms`, `size_bytes`
- [x] 1.8 Завести тем же шагом два индекса аудиозаписей: под отбор захвата — по
рубежу, признаку остановки, паузе и сроку протухания захвата; под список —
по владельцу и колонке упорядочивания страницы вместе с ключом записи.
Индексы заводятся здесь, а не потом: применённый шаг схемы не
переписывается, и добавление индекса будет стоить отдельного шага, а замер
2026-08-15 уже показывал полное сканирование на выборке, сужаемой владельцем
- [x] 1.9 Накатывать схему до подъёма входов и до старта воркеров, отказ шага
ронять стартом с именем шага
- [x] 1.10 Записать числа настроек базы в `docs/database.md`, «Настройки с
числовым значением»
- [x] 1.11 Завести в `config.example.toml` два ключа секции `[storage]`
`busy_timeout_ms` (ожидание занятой базы, миллисекунды) и `read_connections`
(число соединений читающего пула) — с комментарием на каждый: зачем,
границы, единицы; проверить, что загрузчик их читает и старт на пустом
значении не молчит
- [x] 1.12 Снять `EXPLAIN QUERY PLAN` с отбора захвата и со списка, сужаемого
владельцем и страницей: полного сканирования таблицы аудиозаписей ни один
из планов не показывает
## 2. Репозитории на своей базе
- [x] 2.1 Переписать репозиторий аудиозаписи: чтение, сохранение своих полей,
условная запись результата по признаку захвата
- [x] 2.2 Переписать захват одним запросом с `RETURNING`, возвращающим
идентификатор записи и признак этого захвата
- [x] 2.3 Переписать репозитории приложений записи — тексты, структура реплик,
попытки распознавания, журнал событий, темы — с уникальностью по паре
«запись и вид» и «запись и версия разбора»
- [x] 2.4 Переписать репозиторий учётных записей: поиск по ключу, заведение при
первом обращении, разбор двух отказов уникальности
- [x] 2.5 Проверить тестом, что пустая замена не стирает сохранённый текст и
сохранённый ответ провайдера
- [x] 2.6 Отображать сущности в строки базы **по имени**: именованные параметры
запроса и сканирование по имени колонки, без позиционных списков. У
аудиозаписи ссылки на файлы, на структуру реплик, на два вида текста и на
попытку распознавания стоят подряд полями одного типа, и позиционный сдвиг
на одно поле скомпилировался бы молча, положив идентификатор файла в колонку
текста
- [x] 2.7 Убрать поля `location` у сущности файла и `source` у аудиозаписи — из
домена, из отображения в строки базы и из шага начальной схемы. Обе пишутся
сегодня одним значением и не читаются никем, а второе значение `source`
держалось ссылкой из применённого шага, который уходит. Вернуть поле, когда
у него появится читатель, будет стоить одного нового шага схемы
## 3. Файлы записей своим каталогом
- [x] 3.1 Завести раскладку: подкаталог на запись под её идентификатором, имя
файла задаёт сервис, копии `original` и `normalized` лежат вместе
- [x] 3.2 Переписать репозиторий файлов: укладка потоком без чтения в память,
чтение потоком, владелец колонкой, ссылки на две копии порознь
- [x] 3.3 Сохранить единый способ выдать рабочую копию шагу и единственный
способ её убрать
- [x] 3.4 Проверить тестом, что имени отправителя нет ни в имени файла, ни в
пути к нему, ни в журнале
## 4. Маршруты и слои на `net/http`
- [x] 4.1 Переписать подъём сервера и цепочку слоёв без чужого маршрутизатора,
сохранив порядок «ограничитель частоты → узнавание»
- [x] 4.2 Написать свой ограничитель частоты по адресу спрашивающего под корнем
приложения и вывести объявляемую частоту опроса из доли его бюджета
- [x] 4.3 Переписать узнавание по доверенному заголовку на своих типах, убрав
выдачу и приём всякого значения на предъявителя
- [x] 4.4 Переписать обработчики приёма, списка, карточки, текста, пределов и
«кто вошёл» на своих типах
- [x] 4.5 Завести обработчик отдачи файла `GET /app/audiorecords/{id}/file` с
проверкой владельца, параметром `copy` и закрытым перечнем его значений,
кодом `409` на отсутствующую копию и выдачей по диапазону. Негодный диапазон
— неудовлетворимый и множественный — приводить к обычному отказу `400` с
телом сервиса, а не отдавать `416` телом библиотеки
- [x] 4.6 Свести адресное пространство сервиса к одному корню `/app` плюс
`/health` и `/metrics`; проверить тестом ответы на `/api/…`, `/_/` и `/%5f/`
- [x] 4.7 Убрать ветвь отказа `403` и значение `forbidden` из перечня кодов
отказа вместе с её единственным случаем
## 5. Уборка библиотеки
- [x] 5.1 Удалить пакет адаптера хранилища вместе с панелью и правилами панели;
каталог его шагов схемы уходит пунктом 1.4
- [x] 5.2 Убрать из настроек и из образца конфига всё, что относилось к панели и
к её владельцу
- [x] 5.3 Убрать библиотеку и достижимые только через неё модули из `go.mod`,
прогнать `go mod tidy`
- [x] 5.4 Проверить оракулом, что `go list -deps ./... | grep -c pocketbase`
даёт `0`
- [x] 5.5 Снять изъятие правил `internal/archrules`, разрешавшее транспорту
знать адаптер хранилища, и добавить правило на это направление
- [x] 5.6 Переписать правила `internal/archrules` о колонках записи
(`TestКолонкиЗаписиПишутсяИЧитаются`,
`TestКолонкиЗаписиЗаведеныШагомСхемы`) и о едином дескрипторе рубежа
(`TestОтборСпискаБерётРубежиУДескриптора`,
`TestУКаждогоРабочегоРубежаЕстьШаг`, `TestШагиОбъявленыРубежамиДескриптора`)
под новую форму хранилища: сегодня они построены на динамической записи по
имени колонки в API уходящей библиотеки и указывают в удаляемый пакет.
Форма выбрана — именованные параметры и сканирование по имени (пункт 2.6), —
и сторожа инварианта о колонках записи переписываются под неё: они сверяют
**имена** колонок в отображении, в чтении и в шаге начальной схемы, а не
порядок полей. Указывают правила в новый пакет `internal/adapter/repo/sqlite`
и в новый каталог шагов
## 6. Оснастка владельца
- [x] 6.1 Завести в `cmd/devtools` подкоманду возврата остановленной записи в
работу: принимает идентификатор записи, открывает базу каталога данных,
зовёт домен и пишет событие журнала записи с происхождением
`entity.EventOriginHuman`. Колонок подкоманда не пишет: перечень полей,
которые возврат обязан сбросить, исполняет домен — норму держит capability
`pipeline`
- [x] 6.2 Проверить тестом, что возврат в работу сбрасывает всё названное
требованием — признак остановки, признак захвата и срок его протухания,
число отказов, паузу и время входа в рубеж, — и что ближайший захват выдаёт
запись, не дожидаясь протухания прежнего срока
## 7. Проверки и документы
- [x] 7.1 Написать тест параллельного захвата под `-race`: запись достаётся
ровно одному, второй получает отказ по значению признака захвата
- [x] 7.2 Написать тест отдачи файла: чужая запись и несуществующая отвечают
одинаково
- [x] 7.3 Написать тест подъёма на чистом каталоге: до строки о готовности в
журнале нет ни одного отказа
- [x] 7.4 Прогнать `task gate` до зелёного
- [x] 7.5 Обновить документы канона — паспорт, архитектуру, модель угроз,
`docs/database.md` — под ушедшие панель, пространство хранилища и токен
файла; в перечне зависимостей архитектуры назвать `pressly/goose/v3`
пришедшим, а библиотеку хранилища — ушедшей
- [x] 7.6 Записать в `CLAUDE.md` решение владельца от 2026-08-22 о **разовом**
снятии инварианта «миграция, уехавшая на сервер, не переписывается»: причина
— стройка, на сервере данных нет, сервис остановлен; граница — снятие
кончается этим изменением, и шаг начальной схемы подпадает под инвариант как
всякий прежний. Пункт синка: документы канона правятся после задачи
- [x] 7.7 При архивации изменения поправить `Purpose` спеки `storage`: сегодня он
описывает отдачу файла ссылкой по токену, собственную поверхность хранилища
и панель владельца — то есть ушедшее
+74 -153
View File
@@ -20,89 +20,20 @@
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
убран вместе с этим исключением.
## Requirements
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
выключены настройкой коллекции.
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
панель работает суперпользователем, своих экранов профиля сервис не заводит —
закрытие не стоит ничего.
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования узнавание по заголовку
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
полученное.
Отдельная цена у открытого создания записи — захват учётной записи: запись,
заведённая посторонним под чужим именем, досталась бы первому же настоящему
обращению с этим именем.
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
не судья.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Обращение с заголовком запись заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит с доверенного адреса
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Обмен кода у провайдера недоступен
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
#### Scenario: Правка учётной записи снаружи закрыта
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
- **THEN** ответ несёт отказ, а запись остаётся прежней
#### Scenario: Перечисление учётных записей закрыто
- **GIVEN** человек узнан
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
- **THEN** ответ несёт отказ
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
которым назван пришедший, ни адрес почты пользователя.
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
довольно назваться, чтобы стать этим человеком.
не убрать. Требование того же рода, что и запрет писать имя файла на диске: там
строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно
назваться, чтобы стать этим человеком.
Короткий токен файла из перечня ушёл вместе с самим токеном: значений на
предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
@@ -189,8 +120,8 @@
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
норму эту держит capability `storage`.
конвейером, ни запросом к базе: колонка владельца пустого значения не принимает,
и норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
@@ -199,19 +130,19 @@
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и
к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён»
превращает чтение в перебор: по разнице ответов считывается, какие записи
заведены, а идентификатор записи и есть то, что разграничение прячет. Каким
именно ответом это выражено, нормирует capability `archive`: там живут адреса
чтения записи, и держатель нормы обязан быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает:
спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и
отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы
намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает
**спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
@@ -225,6 +156,12 @@ capability `archive`: там живут адреса чтения записи,
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Чужой файл неотличим от несуществующего
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
@@ -233,7 +170,7 @@ capability `archive`: там живут адреса чтения записи,
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
- **THEN** база её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
@@ -326,24 +263,24 @@ MUST отвечать отказом `401`, когда пришедший не
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
обращений заводил бы учётные записи, которые потом не убираются ничем.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
разрешает править свою запись. Расширение области на всё дало бы узнанному
переписать себе ключ на чужое имя и забрать чужой архив.
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
@@ -360,7 +297,7 @@ MUST не подпадать: правка учётной записи, её ч
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
@@ -379,18 +316,17 @@ MUST не подпадать: правка учётной записи, её ч
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
назначается числом и живёт там, где проект держит числовые настройки. На этот
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
обращении, а не однажды выданный срок.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
владелец панели предъявляет свой, и подмена его учётной записью пользователя
отобрала бы у него панель посреди работы.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
@@ -410,17 +346,10 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленный токен побеждает заголовок
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается предъявитель токена
#### Scenario: Протухший токен узнаванию не мешает
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
хранилища
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
@@ -445,12 +374,11 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Поверхность хранилища узнаванию не подпадает
#### Scenario: Область узнавания — корень приложения
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он правит запись коллекции пользователей собственным адресом
хранилища
- **THEN** правка не проходит
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
@@ -480,28 +408,28 @@ MUST не выдавать — ни куки, ни токена сессии. И
Сервис SHALL заводить учётную запись при первом обращении с новым значением
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
коллекции пользователей.
таблицы пользователей.
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
пределу колонки и чистится от управляющих знаков, негодный адрес почты
отбрасывается. Негодное значение необязательного поля MUST не отменять
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
карточку человека молча, посреди его работы.
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное
обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а
правка имени у провайдера меняла бы карточку человека молча, посреди его работы.
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
с чужим адресом досталось бы чужой записи.
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
дотянется, однажды уже оказалась случайной.
Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
его будет нечем — владелец записи назначается один раз и не меняется. Держится
это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет
вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей
запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к
базе, и это работа владельца сервиса, а не спрашивающего.
Одновременные первые обращения одним значением MUST кончаться одной учётной
записью: уникальность держит схема, а не порядок обращений.
@@ -553,7 +481,7 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** два запроса с одним значением заголовка приходят одновременно
- **THEN** в коллекции пользователей появляется ровно одна запись
- **THEN** в таблице пользователей появляется ровно одна строка
- **AND** оба запроса идут от её имени
#### Scenario: Занятая почта не мешает завести запись
@@ -564,11 +492,11 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** заводится своя учётная запись
- **AND** адреса почты у неё нет
#### Scenario: Ключ учётной записи не правится и рукой в панели
#### Scenario: Адреса правки учётной записи у сервиса нет
- **GIVEN** учётная запись заведена
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
- **THEN** сохранение отвергается, а ключ остаётся прежним
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись
- **THEN** такого адреса нет
#### Scenario: Негодное имя не отменяет заведения
@@ -595,12 +523,6 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **THEN** журнал несёт строку о заведении с идентификатором записи
- **AND** значения заголовка в ней нет
#### Scenario: Ключ учётной записи снаружи не правится
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
- **THEN** правка не проходит, а ключ остаётся прежним
### Requirement: Доверенный источник объявлен настройкой
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
@@ -632,4 +554,3 @@ MUST не выдавать — ни куки, ни токена сессии. И
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
+250 -49
View File
@@ -1,50 +1,119 @@
# archive Specification
## Purpose
TBD - created by archiving change app-json-contract. Update Purpose after archive.
Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное
пространство под корнем `/app/`, единая форма отказа, объявленные пределы,
страница своих записей, карточка записи, текст названного вида и файл записи
названной копии.
Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого
приложения — `webapp`, хранение записи и её файлов — `storage`.
Сознательно не описаны: правка записи и её удаление — их приносят отдельные
задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку.
## Requirements
### Requirement: Адреса приложения живут своим пространством
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно
вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он
литерал библиотеки, а не настройка.
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
всякий неизвестный путь, — норму держит capability `webapp`.
Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся:
обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они
молча — тем же адресом начнёт отвечать не тот обработчик.
Корень приложения остаётся прежним, и это решение подтверждается, а не
принимается заново: формы запросов и ответов приложения смена хранилища не
трогает ни одним полем.
Цена переезда называется здесь же. Правило неизвестного пути, по которому
приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не
один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель
частоты хранилища настроен на чужой корень и наших адресов больше не покрывает,
поэтому сервис MUST заводить своё правило под корень приложения.
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
обновления, вправе занять новое имя рядом с нашим, больше нет.
Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность
и выключен умолчанием, поэтому включение нашего правила вводит в действие и его
собственные — на входе, на заведении записей и на его адресах. Принимается
сознательно: без включения наше правило не значит ничего.
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
включение нашего правила вводило в действие и чужие правила на чужих адресах;
платить за это больше нечем — чужих адресов нет.
Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки
человека живут под корнем приложения.
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
MUST не влиять на ключ бюджета ничем.
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
падать обратно на адрес пира.
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
запрос, и меняет он его на каждом запросе.
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
оба ломает либо барьер, либо бюджет.
#### Scenario: Адрес приложения отвечает под своим корнем
- **GIVEN** человек вошёл и предъявил сессию
- **GIVEN** человек узнан
- **WHEN** он спрашивает список своих записей под корнем приложения
- **THEN** ответ приходит от сервиса, а не от хранилища
- **THEN** ответ приходит от сервиса
#### Scenario: Прежние адреса приложения не отвечают
#### Scenario: Пространства хранилища не существует
- **GIVEN** заведена запись
- **WHEN** её спрашивают прежними адресами в чужом пространстве
- **THEN** ответ имеет код `404`
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Адреса панели не существует
- **GIVEN** сервис поднялся
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный его кодом
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
#### Scenario: Ограничитель частоты покрывает адреса приложения
- **WHEN** сервис поднялся
- **THEN** настройки ограничителя несут правило, чей адрес начинается корнем
приложения
- **GIVEN** сервис поднялся
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
- **THEN** лишние получают отказ ограничителя
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
- **THEN** бюджет каждого считается отдельно
- **AND** исчерпание бюджета одним не отказывает другому
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
- **GIVEN** запросы приходят с адреса вне перечня доверенных
- **WHEN** они несут заголовок пересылки с разными значениями адреса
- **THEN** бюджет у них общий и считается по адресу пира
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
спрашивающего, а справа — адрес, приписанный прокси
- **THEN** бюджет считается по правому значению
- **AND** запросы чаще бюджета получают отказ ограничителя
#### Scenario: Цепочка читается всеми строками заголовка
- **GIVEN** запросы приходят с доверенного адреса
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
- **THEN** ключ бюджета берётся из последней строки, а не из первой
### Requirement: Отказ называет причину, а не место
@@ -54,21 +123,24 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
негодный размер страницы, негодный диапазон в запросе файла;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
- состояние, в котором действие недоступно, — `409`: текста или копии файла
запрошенного вида у записи ещё нет;
- отказ базы и всякая неназванная причина — `500`.
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
вместе со своим единственным случаем: им был владелец панели, предъявивший
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
в коде мёртвой.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
адреса, и у него MUST быть определённая ветвь по умолчанию.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
@@ -82,8 +154,8 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
`too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
@@ -97,13 +169,13 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
#### Scenario: Сбой базы виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **GIVEN** база отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
@@ -115,16 +187,10 @@ TBD - created by archiving change app-json-contract. Update Purpose after archiv
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
недоступному состоянию и по сбою базы
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
@@ -468,3 +534,138 @@ MUST браться колонками самой записи, а не стро
называет вида вовсе
- **THEN** ответ имеет код `400`
### Requirement: Файл записи отдаётся адресом приложения
Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET
/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную
спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что
успел записать конвейер, а не состояния записи.
**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со
значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным
контрактом молча.
**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:**
- `original` — файл, принятый от отправителя;
- `normalized` — копия, приведённая к рабочему формату.
Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же
кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как
пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек
увидел бы «не найдено» на своей записи, загруженной минуту назад.
Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному
вводу — но только у **своей** записи.
Порядок проверок MUST быть один: владение записью судится **до** разбора значения
копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей
записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и
телом. Неотличимость чужой записи от несуществующей главнее формы ответа на
негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на
чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой
разнице перебирался бы список заведённых записей одним негодным параметром.
Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям:
запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает
её запросом диапазона, а не повторной загрузкой целиком.
**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же
формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом
библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково:
неудовлетворимый (начало за концом файла) и множественный (в запросе назван
больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких
частей — это отдельный тип содержимого со своими границами, а просит его один
только самодельный запрос, потому что проигрыватель в браузере шлёт один
диапазон.
Причина у требования общая с прочими отказами, рождающимися не в обработчике:
форма тела на адресах приложения одна, и код отказа принадлежит закрытому
перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и
`message`, и приложение разбирает его отдельной веткой — единственной такой на
все адреса.
Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же,
чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу
даёт узнавание, а не выданное значение, нормирует capability `storage`.
Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при
сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи.
**Перечня доступных копий карточка записи не объявляет** — до задачи об экране
прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий
узнаёт отказом состояния на самом обращении за файлом.
Довод, которым перечень доступных видов текста объявляется карточкой всегда, к
копиям файла не относится, и это разные случаи. Видов текста несколько, шаг
завершения пишет их несколькими операциями, поэтому состояние «сплошной текст
есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение
обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и
каждая выводится из рубежа записи, который карточка несёт и так: принятая копия
есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение.
Второе поле повторяло бы рубеж и разошлось бы с ним молча.
Перечень появится тогда, когда у него появится потребитель: экран прослушивания
приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять
контракт, которого никто не разбирает.
#### Scenario: Владелец забирает принятую копию
- **GIVEN** запись принята
- **WHEN** владелец просит её файл копией `original`
- **THEN** ответ несёт содержимое принятого файла и его длину
#### Scenario: Приведённой копии ещё нет
- **GIVEN** запись принята и не дошла до приведения
- **WHEN** владелец просит её файл копией `normalized`
- **THEN** ответ имеет код `409`
- **AND** он отличается от ответа на неизвестный идентификатор
#### Scenario: Копия неизвестна или не названа
- **WHEN** владелец просит файл копией, которой сервис не знает, либо не
называет копии вовсе
- **THEN** ответ имеет код `400`
#### Scenario: Чужой файл неотличим от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** её файл просит другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный просит её файл копией, которой сервис не знает
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи
#### Scenario: Перечня копий в карточке нет
- **GIVEN** запись принята и приведена к рабочему формату
- **WHEN** владелец спрашивает её карточку
- **THEN** поля с перечнем доступных копий файла в ответе нет
#### Scenario: Проигрыватель просит кусок записи
- **GIVEN** запись принята
- **WHEN** владелец просит её файл с указанием диапазона
- **THEN** ответ несёт запрошенный кусок, а не файл целиком
#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом
- **GIVEN** запись принята, и её файл короче запрошенного начала
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range:
bytes=99999999-`
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт
- **GIVEN** запись принята
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком,
называющим два диапазона
- **THEN** ответ имеет код `400`, а не `416`
- **AND** тело несёт поля `error_code` и `message`
- **AND** ответа из нескольких частей сервис не отдаёт
+33 -50
View File
@@ -2,14 +2,16 @@
## Purpose
Приём записи и опрос готовности задачи расшифровки: что считается принятой
записью, что уезжает в ответ и что происходит, когда запись не удалось
прочитать. Плюс наличие входов: с каким из них сервис вправе подняться.
Приём записи: что считается принятой записью, что уезжает в ответ и что
происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из
них сервис вправе подняться. Исход принятой записи её владелец узнаёт карточкой —
норму держит `archive`, и опрос готовности убран 2026-08-15.
Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки.
Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его
возвращение заводит их заново, вместе со связью чата и учётной записи.
## Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
@@ -19,9 +21,7 @@
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
методом: он **заводит аудиозапись**, а не кладёт файл.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
@@ -34,10 +34,8 @@
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться:
идентификатор записи зовётся `id`.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
@@ -46,9 +44,7 @@
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
ветвями.
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
@@ -56,25 +52,20 @@
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку каталога
данных нормирует capability `storage`.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя.
Обязательность владельца при этом MUST держаться и схемой: колонка владельца
пустого значения не принимает вовсе, и норму эту держит capability `storage`.
Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом
до того, как запись попадёт в память, а схема отвечала бы отказом сохранения
после укладки файла.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание
заводит учётную запись само, а предъявителя с собственным токеном хранилища не
существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим
единственным случаем.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
@@ -88,16 +79,9 @@
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** содержимое записи целиком лежит в каталоге данных одним файлом
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
@@ -123,8 +107,8 @@
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и
содержимым своим приёму не подконтрольно.
отправителем, MUST не попадать **ни в имя файла на диске, ни в путь к нему**: оно
приходит извне и содержимым своим приёму не подконтрольно.
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
@@ -132,27 +116,27 @@
подписывает запись».
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
файла на диске расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширениемхвостом после
последней точки.
Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой
библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и
правило перестало быть отменой чужого поведенияоно стало прямым описанием
своего.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
- **THEN** имя файла на диске оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
- **THEN** имя файла на диске оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
#### Scenario: Имя отправителя в имя файла не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **THEN** имя файла на диске не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных
@@ -334,4 +318,3 @@ MUST ограничивать его длину и MUST убирать из не
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет
+67 -17
View File
@@ -10,7 +10,7 @@
перехода, неделимость захвата и срок его протухания, условие записи результата
держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой,
журнал событий записи и молчание конвейера наружу: обращений к отправителю он не
делает вовсе, и свой исход тот узнаёт опросом готовности.
делает вовсе, и свой исход тот узнаёт карточкой записи.
Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы
считаются приговором записи, а какие поводом к повтору**. Второе — не пробел
@@ -20,6 +20,7 @@
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## Requirements
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На
@@ -87,8 +88,9 @@
### Requirement: Захват задачи неделим
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей записи и пометка её захваченной MUST происходить вместе.
Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор
подходящей записи и пометка её захваченной MUST происходить вместе, одним
оператором с возвратом.
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
@@ -99,7 +101,8 @@
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
признаком занятости. Условие записи результата сверяет именно это значение:
захват, перевыданный другому — по протуханию срока или после того, как человек
снял признак остановки в панели, — обязан обращать запись первого в отказ.
снял признак остановки подкомандой оснастки, — обязан обращать запись первого в
отказ.
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
два шага записали бы в одну запись по очереди, испортив её результат.
@@ -170,15 +173,19 @@
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и
без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы
конвейер MUST не трогать.
Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой
оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она
написана про поля, а не про того, чьей рукой правка сделана, и переживёт
появление экранов.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную запись
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **AND** владелец за это время изменил поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
@@ -346,10 +353,36 @@ MUST расти с числом её отказов до объявленног
и машинный текст отказа.
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**.
Время входа сбрасывается по той же причине, что и остальные сторожа: запись,
простоявшая остановленной дольше предела, иначе останавливалась бы снова первым
же захватом, и перезапуск не работал бы вовсе.
стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**:
- признак остановки — время остановки, причину и машинный текст отказа;
- признак захвата и срок его протухания;
- число отказов;
- паузу перед повтором;
- время входа в рубеж.
Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни
отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до
протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по
прежнему значению. Оставленное время входа в рубеж останавливает запись снова
первым же захватом, если остановленной она простояла дольше предела, и перезапуск
не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на
накопленный срок.
Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись,
а поля выше сбрасывает домен одним действием. Правка колонок мимо домена
повторяет перечень вторым местом, и второе место расходится с первым молча.
Возврат в работу MUST писать событие журнала записи с происхождением «человек».
Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в
журнале от записи, которую конвейер вёл без остановок, а происхождение события
перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не
остаётся.
Инструментом возврата сегодня служит подкоманда набора инструментов
разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи.
Норма написана про поля и про домен, а не про инструмент, и появление экрана её
не трогает.
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
восстанавливаются одинаково — снятием признака, — и различие между ними
@@ -389,6 +422,19 @@ MUST расти с числом её отказов до объявленног
- **THEN** число отказов, пауза и время входа в рубеж сброшены
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
#### Scenario: Возврат в работу освобождает захват
- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит
- **WHEN** признак остановки снимают
- **THEN** признака захвата и срока его протухания на записи нет
- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока
#### Scenario: Возврат в работу виден в журнале записи
- **GIVEN** запись остановлена
- **WHEN** её возвращают в работу
- **THEN** в журнале событий записи есть событие с происхождением «человек»
### Requirement: Время в рубеже ограничено
У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при
@@ -608,8 +654,13 @@ MUST не быть привязаны к отдельному шагу: кажд
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса;
адрес карточки и содержимое ответа нормирует capability `archive`.
своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое
ответа нормирует capability `archive`.
Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он
видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным
хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется
по-прежнему, а читается запросом к базе, пока экрана нет.
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
@@ -628,8 +679,8 @@ MUST не быть привязаны к отдельному шагу: кажд
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме
норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
@@ -644,4 +695,3 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** владелец записи спрашивает её карточку
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
+42 -11
View File
@@ -1,8 +1,19 @@
# recognition Specification
## Purpose
TBD - created by archiving change record-centric-model. Update Purpose after archive.
Что принадлежит внешнему распознавателю и как это лежит у нас: попытка
распознавания отдельной строкой, сырой ответ провайдера целиком, структура
реплик, построенная из сохранённого ответа, и граница, за которую разбор чужого
формата не выходит.
Движение записи по рубежам нормирует `pipeline`, хранение текста и структуры —
`storage`, закрытость содержимого — `storage` же.
Сознательно не описан формат ответа конкретного провайдера: он живёт в адаптере
и в записках разведки, а не в норме поведения.
## Requirements
### Requirement: Попытка распознавания хранится отдельно от записи
Сервис SHALL держать всё, что принадлежит внешнему распознавателю, отдельной
@@ -39,24 +50,37 @@ TBD - created by archiving change record-centric-model. Update Purpose after arc
### Requirement: Сырой ответ провайдера сохраняется целиком
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
пришёл, и MUST хранить его вложением, а не колонкой строки попытки.
пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой
строки попытки.
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
пересчитается из сохранённого без повторной оплаты.
Вложением, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
хранилище читает запись целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе.
Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
репозиторий читает строку целиком: ответ на многочасовую запись, положенный
колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит,
нормирует capability `storage`, требование «Файл записи живёт в хранилище»:
третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при
этом не считается — их у записи по-прежнему две.
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
записью: поле вложения помечено защищённым, правило просмотра пускает только
владельца связанной записи, ссылка не попадает ни в журнал, ни в метку метрики.
Норму держит capability `storage`, требование «Содержимое записи закрыто во всех
коллекциях, где лежит»; здесь она названа потому, что коллекция попыток — то
место, куда содержимое приезжает впервые.
записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**:
адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а
всякий адрес, отдающий содержимое записи, MUST судить владельца связанной
аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило
просмотра коллекции, которыми закрытость держалась прежде, — механизмы
встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала
зависеть от настройки, которую мы не писали.
Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку
метрики, ни в ответ отправителю.
Норму держит capability `storage`, требование «Содержимое записи закрыто везде,
где лежит»; здесь она названа потому, что попытка распознавания — то место, куда
содержимое приезжает впервые.
#### Scenario: Ответ сохранён и читается позже
@@ -70,6 +94,14 @@ TBD - created by archiving change record-centric-model. Update Purpose after arc
- **WHEN** шаг опроса читает строку попытки
- **THEN** сохранённый ответ в память при этом не читается
#### Scenario: Адреса чтения сохранённого ответа у сервиса нет
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой
записи
- **THEN** такого адреса у сервиса нет
- **AND** содержимого он не получает
### Requirement: Структура реплик строится из сохранённого ответа
Сервис SHALL строить структуру реплик записи из сохранённого ответа провайдера и
@@ -148,4 +180,3 @@ MUST не обращаться к провайдеру повторно ради
- **WHEN** провайдер отвечает идентификатором операции
- **THEN** идентификатор сохраняется в строке попытки
- **AND** повторная отправка той же записи не заводится
+406 -284
View File
@@ -3,10 +3,11 @@
## Purpose
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
поверхность хранилища и панель владельца.
версионированный накат схемы при подъёме, правила чтения и записи базы, отдача
файла его владельцу с проверкой при каждом обращении.
Приём и опрос готовности нормирует `intake`, вход и сессию`access`, попытку
Приём записи нормирует `intake`, узнавание пришедшего`access`, адреса, по
которым приложение спрашивает запись и её файл, — `archive`, попытку
распознавания у внешнего провайдера — `recognition`.
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
@@ -14,6 +15,7 @@
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
архивом 2026-08-11, а удаление приносит задача `delete-record`.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
@@ -26,7 +28,30 @@ MUST завести свою схему и принимать записи св
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой
базой, а не порядком файлов на диске.
Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс,
оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а
следующий запуск применяет его второй раз — и второе применение отказывает на
заведённой таблице, роняя старт на шаге, который на самом деле цел.
Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй
процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо
отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём
бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два
наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не
описывает ни один шаг.
Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а
не из порядка чтения каталога: порядка обхода файловая система не обещает, а
разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз.
Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.
**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага
MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом
на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их
становятся сотни, и первопричина в них теряется.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
@@ -42,29 +67,223 @@ MUST завести свою схему и принимать записи св
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
- **THEN** он не заводит схему второй раз и не теряет прежних записей
#### Scenario: Схема накатана до первой строки о готовности
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** до строки журнала о готовности схема приведена целиком
- **AND** ни одного отказа в журнале до неё нет
#### Scenario: Старт, оборванный между шагом и отметкой о нём
- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём
- **WHEN** сервис запускается снова
- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий
шаг
- **AND** шаг не применяется второй раз
#### Scenario: Отказ шага схемы роняет старт
- **GIVEN** шаг схемы не применяется
- **WHEN** сервис запускается
- **THEN** старт кончается отказом, называющим шаг
- **AND** ни один вход не поднят
### Requirement: База принимает одного писателя
Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести
отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное
соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST
задаваться числом, а не оставаться умолчанием драйвера.
Все три настройки MUST задаваться **строкой подключения обоих пулов** — и
пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение
внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она
выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт
соединения и заводит новые по мере надобности, поэтому запрос, выполненный один
раз после открытия, настраивает одно соединение из многих, а остальные остаются с
умолчанием — молча. На включённых внешних ключах держатся требования «Учётная
запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл
без владельца не сохраняется»: с выключенными все три зеленеют на том соединении,
где настройку успели поставить, и не работают на соседнем.
Требование заводится потому, что эту настройку прежде держала за нас чужая
библиотека двумя пулами. Драйвер пишет единственным соединением: несколько
воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и
получают его на записи результата шага, то есть после оплаченной работы.
**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем
соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция,
начатая на читающем соединении и позже пытающаяся писать, получает отказ по
занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite
не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под
правило подпадают захват записи и накат шага схемы: каждый читает состояние,
которое сам же меняет.
**Узнавание под правило не подпадает, и устроено оно двумя соединениями.**
Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая
транзакция MUST открываться только тогда, когда запись не нашлась. Слой
узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона
при проигрывании, — а заводится учётная запись один раз за жизнь человека:
пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к
единственному пишущему соединению. Очередь эта ожиданием занятой базы не
ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд
ожидания видны только замером.
Окно между двумя соединениями MUST закрываться **повторным поиском внутри
транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо
взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не
порядок обращений.
Значения ожидания и числа соединений MUST жить там, где проект держит числовые
настройки, и MUST не повторяться второй константой рядом.
#### Scenario: Несколько воркеров пишут разом
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** все они дошли до записи своего результата одновременно
- **WHEN** результаты записываются
- **THEN** каждый записан, и ни один не отказал по занятости базы
#### Scenario: Настройки базы применены при подъёме
- **WHEN** сервис поднялся на чистом каталоге данных
- **THEN** у базы включён журнал упреждающей записи
- **AND** ожидание занятой базы равно объявленному числу
#### Scenario: Внешние ключи включены на соединении читающего пула
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA
foreign_keys`
- **THEN** ответ — `1`
#### Scenario: Узнавание известного не ждёт писателя
- **GIVEN** учётная запись с этим логином уже заведена
- **AND** пишущее соединение занято открытой транзакцией
- **WHEN** приходит следующее обращение тем же логином
- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя
- **AND** второй учётной записи не заведено
#### Scenario: Составная операция не отказывает по занятости
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** каждый выполняет операцию, которая читает состояние записи и следом его
пишет
- **WHEN** операции идут одновременно
- **THEN** каждая завершена, и ни одна не отказала по занятости базы
### Requirement: Время и идентификаторы приходят из одного места
Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в
RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`.
Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет
только сам сервис.
Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
совпадает с хронологией, и отбор по колонке времени работает без разбора
значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
положили, а отбор захвата сравнивает строки — колонка, заполненная то одним
видом, то другим, обращает условие срока протухания в постоянную истину или ложь
молча, и запись не выдаётся ни одному воркеру никогда.
**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой
точки чтения времени, которую держит линтер проекта. Умолчаний вида
`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам:
умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка,
забывшая проставить время, при умолчании проходит молча, а без него падает
громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим
форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся
одно.
Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и
ставить его MUST приложение единой точкой при заведении строки. Это то, что
конвенция проекта объявляет нормой; расхождение, при котором идентификаторы
выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним.
Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет
вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability
`archive`.
#### Scenario: Вид времени один на все колонки
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** смотрят колонки времени в применённой схеме
- **THEN** все они объявлены одним типом и несут время одним видом
- **AND** умолчания времени ни у одной из них нет
#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково
- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками
- **AND** обе стоят на одном рубеже и пригодны к захвату
- **WHEN** воркеры разбирают очередь
- **THEN** захвату выдаются обе
- **AND** ни одна не остаётся в очереди навсегда
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого
каталога MUST задавать он сам. Файл MUST адресоваться записью, которой
принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает
его, ничего не зная о раскладке.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и
MUST давать убрать запись целиком одним движением, не перебирая имена по маске.
Плоского каталога, где копии различаются приставкой в имени, MUST не
заводиться.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
Содержимое записи MUST не читаться в память целиком ни при укладке, ни при
чтении: расчётный потолок записи — шесть часов, и такая запись в память не
помещается.
**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что
переименование в его пределах не копирует содержимое и не может оборваться на
середине.
Порядок MUST быть один: строка о файле заводится **после** того, как содержимое
лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку,
указывающую на файл, которого ещё нет или который короче принятого.
Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
записи со строкой файла не сверяются — так требует «Аудиозапись — центральная
сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в
конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый
результат. Атомарная укладка — единственное, что этого не допускает.
Отсюда две нормы о неудачах:
- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST
быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, —
штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
принятой копии это мусор, на который не ссылается ничто и о котором узнать
неоткуда;
- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ
источника: временного имени не остаётся, рабочего имени не появляется, строки о
файле нет. Записи, наполовину принятой, человек не видит.
Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же
подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки
он ехал бы в память при каждом опросе готовности — этого capability `recognition`
избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого
и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под
ту же атомарную укладку и под ту же уборку записи одним движением, что и копии
аудио.
Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи
по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис
отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ
читают снаружи, у сервиса нет вовсе.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела
запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь
не «без предела», а величины на два-три порядка меньше нужного, и оставленные как
есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая
запись исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
@@ -76,20 +295,37 @@ MUST завести свою схему и принимать записи св
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
#### Scenario: Принятая запись легла в каталог данных
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
- **WHEN** запись принята
- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью
- **AND** второго каталога записей рядом не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
#### Scenario: Копии одной записи лежат вместе
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание
- **WHEN** смотрят, где лежат её файлы
- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат
под идентификатором этой записи
- **AND** второго места, где лежит что-то из них, нет
#### Scenario: Источник оборвался посреди потока
- **GIVEN** отправитель шлёт запись и обрывает поток на середине
- **WHEN** укладка отказывает
- **THEN** строки о файле не заведено
- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи
не остаётся
#### Scenario: Запись длиннее умолчания принимается
- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути
приёма
- **THEN** она ложится в каталог данных, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
@@ -101,209 +337,92 @@ MUST завести свою схему и принимать записи св
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца
сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
Правило MUST пускать только владельца файла: незаданное означает «только владелец
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
отдавало чужое аудио тому, кто знает идентификатор записи.
Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого
токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и
владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит
до файла сразу, а не через срок жизни выданного значения.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Обращение к файлу чужой записи MUST быть неотличимо от обращения к
несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы
чтение в перебор заведённых записей.
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
хранилища, а не недосмотр.
Каким адресом файл уходит и как называется вид копии, нормирует capability
`archive`: там живут адреса приложения, и держатель нормы обязан быть один.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не
по адресу приложения.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в
метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из
журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку
не убрать.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не
называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее
хранилище MUST не называть полного адреса объекта. И то и другое кончается в
журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
#### Scenario: Владелец забирает свой файл
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий узнан и взял токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** владелец записи просит её файл
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают неузнанным
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** файл просят неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Токен файла выдаётся узнанному по заголовку
#### Scenario: Значения на предъявителя сервис не выдаёт
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он просит у хранилища токен файла
- **THEN** токен выдаётся
- **GIVEN** человек узнан
- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл
- **THEN** такого адреса у сервиса нет
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
- **THEN** файл читается из каталога данных и шаг проходит
#### Scenario: Ссылка ведёт в никуда
#### Scenario: Файл записи, которой нет
- **WHEN** запрашивают ссылку на запись, которой нет
- **WHEN** просят файл записи с неизвестным идентификатором
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
#### Scenario: По журналу путь к файлу не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
- **THEN** имени, под которым файл лёг в каталог данных, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **GIVEN** файл записи не читается с диска
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
признака остановки в панели MUST возвращать запись в работу с сохранённого
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
его протухания, паузу, число отказов — и MUST заново ставить время входа в
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
срока, останавливается от первого же отказа или останавливается снова первым же
захватом по пределу времени, — и не узнает об этом.
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень рубежей — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и заведена
- **WHEN** владелец отбирает записи по идентификатору принятой
- **THEN** он видит её строкой со своим рубежом
- **AND** её файл скачивается из той же строки
#### Scenario: Остановленную запись вернули в работу правкой в панели
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
прежнего захвата
- **AND** остановленной она простояла дольше предела времени в рубеже
- **WHEN** владелец снимает признак остановки
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
- **AND** время входа в рубеж поставлено заново
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
приёму не мешает.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
входом заводить ничью запись стало некому, и обязательность переезжает из одного
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
потом никому.
Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь
объявлена внешним ключом на учётную запись и обязательна. Пока обязательность
жила в одном приёме, ничью запись заводили руками мимо него, она уходила в
конвейер, стоила денег на распознавание и не доставалась потом никому.
Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у
него нет. Обязательность от этого не отменяется — она перестала зависеть от того,
кто пишет, и стала свойством схемы.
Владелец MUST не назначаться и не меняться конвейером.
@@ -318,8 +437,15 @@ MUST завести свою схему и принимать записи св
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или руками в панели
- **THEN** хранилище её не сохраняет
конвейером или запросом к базе
- **THEN** база её не сохраняет
#### Scenario: Владелец, которого нет, не принимается
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни
одна учётная запись
- **THEN** база её не сохраняет
#### Scenario: Конвейер владельца не назначает
@@ -330,13 +456,11 @@ MUST завести свою схему и принимать записи св
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
записью, — а отдача файла MUST пускать к нему только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
файла MUST не допускать пустого значения наравне с колонкой записи. Разное
правило у записи и у её файла читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
@@ -348,29 +472,29 @@ MUST получать владельца своей записи. Иного и
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по
которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
- **WHEN** другой узнанный просит файл этой записи
- **THEN** содержимого он не получает
- **AND** ответ тот же, что и на неизвестный идентификатор записи
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **WHEN** он просит файл своей записи
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
- **THEN** база его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
@@ -382,69 +506,63 @@ MUST получать владельца своей записи. Иного и
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
другую подменяет сообщением про обязательную связь — подсказкой, по которой
владелец панели пойдёт удалять записи руками.
аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой
схемой — обязательной связью, которая не даёт убрать строку, пока на неё
ссылаются.
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
три: аудиозаписи, файлы и словарь тем.
Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним
местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте,
пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не
существует.
Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления
может стать больше одного, а проверка, записанная у одного, у остальных читалась
бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
запуска, а окружение проверок его не ставило вовсе.
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
поверхность.
Цена требования названа прямо: владелец панели упирается в отказ, а способа
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
тупик, а не недосмотр.
Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе —
его приносит задача про удаление записи. До неё удаление учётной записи с
записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым
учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление
запросом к базе.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её удаляют
- **WHEN** её строку удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её удаляют
- **THEN** удаление не проходит, а отказ называет причину нашими словами
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её удаляют
- **WHEN** её строку удаляют
- **THEN** удаление проходит
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней
приложено, — отдельными строками со ссылками на запись. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
расшифровка лежит колонкой той же строки и читается при каждом захвате.
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
@@ -463,12 +581,11 @@ MUST получать владельца своей записи. Иного и
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
ноль. Решение владельца 2026-08-15.
недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные
которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в
этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
@@ -484,9 +601,7 @@ MUST получать владельца своей записи. Иного и
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
первую.
прислал» и «что лежит сейчас».
#### Scenario: Список читается без содержимого
@@ -500,55 +615,61 @@ MUST получать владельца своей записи. Иного и
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Пустая длительность в схему не ложится
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым
размером
- **THEN** база её не сохраняет
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
### Requirement: Содержимое записи закрыто везде, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: её правило просмотра MUST не открывать содержимое
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
MUST быть помечено защищённым.
Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её
строки перечисляются или читаются мимо проверки владельца связанной записи.
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
его раньше, чем появится потребитель, значит открывать поверхность впрок:
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
Требование распространяется на все приложения записи — тексты, структуру реплик,
попытку распознавания с её сохранённым ответом, журнал событий и темы — и
заводится потому, что содержимое лежит не в одной строке, а в нескольких.
Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы
как снятое.
Требование распространяется на все коллекции приложений — тексты, структуру
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
отдавала бы расшифровку любому, кто её знает, без сессии.
Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт
наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит,
нормирует требование «Файл записи живёт в хранилище»: третьим файлом в
подкаталоге записи.
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
ответ отправителю — теми же словами, какими это нормировано для файла записи.
Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в
журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это
нормировано для файла записи.
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
просмотра значит «только владелец панели» и отнимает содержимое у самого
владельца записи, а незащищённое поле файла отдаёт его всем.
Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где
лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость
держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит
владельца.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним вошедшим и прошла распознавание
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи
- **THEN** содержимого он не получает
#### Scenario: Без сессии содержимое не отдаётся
#### Scenario: Неузнанному содержимое не отдаётся
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
- **WHEN** содержимое записи запрашивают неузнанным
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисление приложений закрыто
#### Scenario: Перечисления приложений записи не существует
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
- **THEN** приходит отказ
- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток
распознавания
- **THEN** такого адреса у сервиса нет
### Requirement: Ссылки на исходник и приведённую копию живут порознь
@@ -603,25 +724,27 @@ MUST быть помечено защищённым.
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
можно только перебором всех его записей.
Отдельной таблицей, а не набором строк в записи, — потому что перечень тем
человека нужен целиком перед каждым обращением к модели, а собрать его из
наборов строк можно только перебором всех его записей.
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
десятка тем, и словарь распухает за неделю.
Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт
два десятка тем, и словарь распухает за неделю. То же число сервис объявляет
приложению — норму держит capability `archive`, — и второй константы рядом MUST
не заводиться.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
закрепляются раньше, чем известен их потребитель.
шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются
раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
@@ -642,7 +765,7 @@ MUST быть помечено защищённым.
Требование стоит на повторном опросе одной и той же операции распознавания.
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
человек снял признак остановки в панели. Провайдер при этом вправе ответить
человек снял признак остановки подкомандой оснастки. Провайдер при этом вправе ответить
пустым потоком, отказом это не считается, и безусловная замена стирала бы
расшифровку живого человека — без следа и без возврата, потому что сервис
объявлен архивом и удаления по требованию не знает.
@@ -661,4 +784,3 @@ MUST быть помечено защищённым.
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
- **THEN** сохранённая расшифровка остаётся прежней
- **AND** шаг завершается без отказа
+88 -23
View File
@@ -9,6 +9,7 @@
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
## Requirements
### Requirement: Приложение отдаётся самим бинарником
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
@@ -68,21 +69,36 @@
### Requirement: Неизвестный путь вне корней открывает приложение
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у
сервиса остался **один**`/app` у приложения; отдельными адресами стоят
`/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его
собственным пространством и панели администратора у сервиса не осталось, и
адресов под этими именами не существует. Прежние пути хранилища и панели поэтому
отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит,
ничем не отличается от любого другого свободного имени, а второй перечень
«когда-то занятых корней» разошёлся бы с первым молча.
Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака,
раскодируется в тот же путь и попадает в то же правило — правило одно, и особого
случая у него нет.
Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
`/api` не достался бы никому и уехал бы разметкой.
`/app` не достался бы никому и уехал бы разметкой.
Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения,
адресом приложения при этом не является и потому MUST отвечать как **неизвестный
путь под корнем приложения** — узнанному `404` телом отказа приложения,
неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем.
Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка
приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая
же сборка.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
@@ -108,9 +124,18 @@
#### Scenario: Голый корень разметкой не подменяется
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без
косой черты
- **THEN** тело ответа — не разметка приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем
- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения
точно и без косой черты
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
@@ -125,6 +150,19 @@
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний путь хранилища открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес панели открывает приложение
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный кодом этого знака
- **THEN** оба ответа имеют код `200`
- **AND** тело каждого — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
@@ -139,11 +177,6 @@
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
- **THEN** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
@@ -186,9 +219,19 @@
### Requirement: Путь, отданный приложению, в журнал не идёт
Сервис SHALL записывать о таком запросе **исход из закрытого перечня**
разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в
один свой журнал. То же относится к меткам метрик.
Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути,
а самого запрошенного пути MUST не записывать ни в один свой журнал. То же
относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса
наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них
не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного
приложению, к строке добавляется **исход из закрытого перечня** — разметка,
ресурс, отказ.
Правило MUST накрывать обе половины адресного пространства — и путь вне корней
сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не
меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но
множеством значений под корнем распоряжается спрашивающий ровно так же, как и
вне его.
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
@@ -197,8 +240,12 @@
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
перечню известных.
Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь
целиком вместе с адресом отправителя. Требование относится к обоим.
Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и
пишет он прочитанный идентификатор, а не тот, что стоял в запросе.
Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком
вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а
перестало зависеть от настройки чужого журнала, которую мы не писали.
#### Scenario: Путь не доезжает до журнала
@@ -211,6 +258,25 @@
- **WHEN** приходит запрос на путь длиной в тысячу знаков
- **THEN** записи о нём не растут вместе с длиной пути
#### Scenario: Путь под корнем приложения журнал не пишет
- **GIVEN** пришедший не узнан
- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному
объявленному образцу адреса
- **THEN** записи о нём не несут этого пути
- **AND** не растут вместе с его длиной
#### Scenario: Объявленный образец адреса приложения в журнале различим
- **WHEN** приходит запрос на объявленный адрес приложения
- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь
#### Scenario: Второго журнала у сервиса нет
- **GIVEN** сервис поднялся
- **WHEN** приходит запрос на путь вне корней сервиса
- **THEN** запись о нём появляется только в журнале сервиса
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
@@ -272,4 +338,3 @@
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании