Files
transcriber/openspec/specs/intake/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

24 KiB
Raw Blame History

intake Specification

Purpose

Приём записи: что считается принятой записью, что уезжает в ответ и что происходит, когда запись не удалось прочитать. Плюс наличие входов: с каким из них сервис вправе подняться. Исход принятой записи её владелец узнаёт карточкой — норму держит archive, и опрос готовности убран 2026-08-15.

Вход у сервиса один — приём по HTTP, — и описан он тем, что нормируют проверки. Второй вход, Telegram, убран 2026-08-14 вместе со своими требованиями; его возвращение заводит их заново, вместе со связью чата и учётной записи.

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 путь к этому файлу не содержит его тоже

Requirement: Отказ чтения метаданных

Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать принятую запись. Ответ MUST иметь код 400: причина отказа — присланная запись, а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит журналу, а не отправителю.

Отображение этой ошибки в код и сообщение живёт одним местом на все адреса приложения; норму держит capability archive.

Scenario: Источник метаданных вернул ошибку

  • GIVEN источник метаданных не может прочитать запись
  • WHEN программа шлёт POST /app/audiorecords с этой записью
  • THEN ответ имеет код 400 и несёт сообщение, пригодное человеку
  • AND аудиозаписи не заводится

Requirement: Имя файла, данное отправителем, не попадает в журнал

Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, откуда строку не убрать.

Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные логи.

Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, нормирует требование ниже; наружу расширение выходит только приведённым к известному виду — этому отдано отдельное требование.

Оговорка про второй вход из требования ушла вместе с ним: имя, данное отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и сценарии судят именно его.

Scenario: Имя записи не видно в журнале принятой записи

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт POST /app/audiorecords с записью, чья основа имени несёт опознаваемую строку при обычном расширении .mp3
  • THEN ни одна журнальная запись приёма этой строки не содержит
  • AND расширение .mp3 в журнале допустимо

Scenario: Имя записи не видно в журнале при отказе приёма

  • GIVEN источник метаданных не может прочитать запись
  • WHEN программа шлёт POST /app/audiorecords с записью, чья основа имени несёт опознаваемую строку
  • THEN ни одна журнальная запись приёма, включая запись об ошибке, этой строки не содержит

Requirement: Журнал приёма прослеживает запись

Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и удаление имени отправителя прослеживаемости не отнимает.

Расширение засчитывается собственным полем журнальной строки. Имя, под которым файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает ссылку целиком. Норму держит capability storage.

Scenario: Идентификатор, расширение и размер на месте

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт POST /app/audiorecords с записью
  • THEN журнал приёма несёт идентификатор заведённого файла, расширение принятой записи и её размер в байтах

Scenario: Имени файла в хранилище в журнале нет

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт POST /app/audiorecords с записью
  • THEN имени, под которым файл лёг в хранилище, в журнале приёма нет

Requirement: Метка метрики несёт только известное расширение

Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем употребить его меткой метрики: расширение приводится к нижнему регистру и сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST заменяться единым значением other. Перечень — mp3, wav, ogg, oga, opus, flac, m4a, aac, wma, mp4, mkv, mov, avi, webm, плюс audio: последнее не формат, а собственное умолчание сервиса на случай имени без расширения, и различать его от чужого хвоста метка обязана.

Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа временных рядов, которым иначе распоряжается анонимный отправитель.

Требование намеренно шире приёма: под него подпадает и метка шага конвертации. Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе с ней.

Имя файла в хранилище это требование не трогает: там расширение остаётся тем, каким пришло, — это уже нормировано требованием «Имя файла в хранилище».

Настоящий формат записи, попавшей в other, остаётся видимым в журнале: значение other в метке означает «расширение не из перечня», а само оно стоит полем журнальной строки приёма и полем формата строки конвертации.

Scenario: Незнакомое расширение наружу не выходит

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт запись с именем, чей хвост после последней точки не принадлежит перечню известных форматов
  • THEN метка метрики принимает значение other
  • AND имя файла в хранилище сохраняет пришедшее расширение

Scenario: Известное расширение идёт как есть

  • GIVEN источник метаданных читает запись и отдаёт её длительность
  • WHEN программа шлёт запись с именем sample.MP3
  • THEN метка метрики принимает значение mp3

Requirement: Поднятые входы видны наблюдателю

Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной метрикой и MUST выставлять метку только тому входу, который у сервиса есть. Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная единица рядом с ним — как исправность того, чего нет.

Проверяемое здесь одно — набор меток, и это честнее прежнего. Вход остался один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден неудачей чтения самих метрик.

Различать поднятый и неподнятый вход признак MUST снова, как только входов у сервиса станет больше одного.

Scenario: В метриках только оставшийся вход

  • GIVEN сервис поднялся
  • WHEN наблюдатель читает метрики
  • THEN признак поднятости несёт метку входа HTTP со значением единицы
  • AND метки убранного входа Telegram в метриках нет вовсе

Requirement: Имя файла отправителя подписывает запись

Приём SHALL класть имя файла, данное отправителем, в собственную колонку аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт название, которое дал человек либо посчитала языковая модель, и одной колонкой на оба смысла посчитанное название затирало бы то, по чему запись узнают, — а вернуть затёртое было бы неоткуда.

Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не сочиняет.

Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель.

Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет.

Scenario: Имя доходит до записи

  • GIVEN отправитель предъявил сессию
  • WHEN он шлёт запись с именем разговор.mp3
  • THEN колонка имени файла у заведённой записи несёт разговор.mp3

Scenario: Заголовок принятой записи пуст

  • GIVEN отправитель предъявил сессию
  • WHEN он шлёт запись с именем разговор.mp3
  • THEN колонка заголовка у заведённой записи пуста

Scenario: Длинное и грязное имя приходит обрезанным и очищенным

  • GIVEN отправитель предъявил сессию
  • WHEN он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
  • THEN колонка имени файла несёт имя не длиннее предела
  • AND управляющих знаков в нём нет