- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
57 KiB
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: Читать норму по новому требованию. Закрытость держится тем, что адреса чтения содержимого пишет сервис и каждый судит владельца связанной записи, а не правилом просмотра коллекции и не пометкой поля.