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

46 KiB
Raw Blame History

Архитектура

Обзор: как сложено и где что работает. Поведение системы здесь не описывается — нормативно оно живёт в openspec/specs/. Места, где оно всё-таки описано, помечены маркером долга и переезжают туда первой же задачей, которая их трогает.

Документ описывает сегодняшнее устройство. Куда проект идёт — в passport.md и в tasks/BACKLOG.md; что из этого ещё не решено — в разделе «Открытые вопросы».

Заведённые capability нормируют поведение сервиса для его потребителей — все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе: у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением от 2026-08-13 его нормы живут в самих шагах, их проверках и conventions/go-linters.md.

  • intakeприём по HTTP плюс наличие входов: приём только от узнанного, имя отправителя не доходит ни до хранилища, ни до журнала, метка метрики несёт только известное расширение, а наблюдатель видит единственный поднятый вход. Задачи http-handler-tests-never-green и no-user-filename-in-log 2026-08-11, pocketbase-storage и oidc-login 2026-08-12, local-run-without-telegram-token 2026-08-13, remove-telegram-intake 2026-08-14, storage-without-pocketbase 2026-08-22;
  • pipeline — пустой прогон воркера, захват задачи и срок его протухания, число попыток, остановка признаком, пауза перед повтором и молчание конвейера наружу: задачи errors-as-instead-of-typecast 2026-08-11, pocketbase-storage 2026-08-12, local-run-without-telegram-token 2026-08-13, remove-telegram-intake 2026-08-14 и storage-without-pocketbase 2026-08-22. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел Purpose самой спеки;
  • storage — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задачи pocketbase-storage 2026-08-12 и storage-without-pocketbase 2026-08-22. Последняя убрала встроенное хранилище целиком: база стала своей, файлы — своим каталогом, панель владельца исчезла и не заменена ничем;
  • recognitionпопытка распознавания у внешнего провайдера: что о ней хранится, почему сырой ответ сохраняется целиком и вложением, как из сохранённого строится структура реплик без повторной оплаты и почему разбор формата провайдера не доходит до конвейера. Задача record-centric-model 2026-08-14;
  • archiveархив своих записей глазами приложения: пространство адресов /app/ и единая форма отказа с машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само чтение — страница записей ключом, карточка без текста и текст названного вида. Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задача json-api-for-spa 2026-08-15;
  • webappприложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что делает обновление страницы посреди него и что человек видит, открыв его. Здесь же правило неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения ответов и то, что раздача пишет в журнал. Задача spa-skeleton 2026-08-15;
  • access — кто пришёл в сервис и пускают ли его дальше: узнавание по заголовку доверенного источника, заведение учётной записи первым обращением и то, какие адреса остаются открытыми. Собственный вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей trusted-header-login — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. Задачи record-ownership и remove-telegram-intake 2026-08-14.

Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability.

Принципы

  • Один процесс. HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно.
  • Очередь таблицей. Состояние задачи лежит таблицей базы; неделимость захвата и порядок выборки нормирует pipeline, «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, ADR, сравнение кандидатов в research/job-queue.md. Решение пережило уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него одно слово — таблица перестала быть коллекцией.
  • Шаг конвейера идемпотентен по повтору. Что делает срок захвата и когда задача возвращается в работу, нормирует pipeline, «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения.
  • Чистая архитектура. Зависимости направлены внутрь, к домену: внутренний слой не знает внешнего никогда. internal/service знает только internal/contract; ffmpeg, Yandex и хранилище подставляются в точке входа cmd/transcriber. Слои, их дома и словарь модели — раздел «Слои и модель домена» ниже. Правило механизировано тестами-сканерами internal/archrules, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие, разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей, и правило на это направление заведено впервые.

Слои и модель домена

Подход — чистая архитектура. Зависимость идёт только внутрь: домен не знает ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах и реализацией в инфраструктуре.

Слой Дом Что живёт Чего не знает
Домен internal/entity сущности, объекты-значения, доменные события, инварианты значениями ничего, кроме стандартной библиотеки и единой точки времени internal/clock
Порты internal/contract интерфейсы репозиториев и внешних служб, типизированные ошибки реализаций
Прикладной слой internal/service шаги конвейера: порядок, повтор, приговор адаптеров и входов
Инфраструктура internal/adapter репозитории, ffmpeg, Yandex, шаги схемы ядра и входов
Входы internal/controller HTTP и пул воркеров друг друга
Сборка cmd/transcriber подстановка реализаций в порты, подъём сервера и пула

Направления держат тесты-сканеры internal/archrules — все, кроме чистоты самого домена. Её не держит ничто: правила смотрят ядро, входы и адаптеры, а импорт внешней библиотеки в internal/entity сегодня пройдёт молча.

Модель домена ведётся тактическими шаблонами DDD. Шаблон называется здесь вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:

  • Сущностьentity.AudioRecord: у неё идентичность и поведение (MoveToState, Halt, Resume, Postpone), а не набор полей при сервисе;
  • корень агрегата — она же: файлы, тексты, структура, попытки распознавания и журнал событий принадлежат записи и живут её идентификатором, а правит агрегат держатель захвата;
  • объект-значениеentity.Stage со своими сроками, entity.StuckLimits, entity.Replica, entity.RecognitionResult: сравниваются по значению и своей идентичности не имеют;
  • доменное событиеentity.RecordEvent: что случилось с записью, чьей рукой и чем кончилось;
  • репозиторий — интерфейсы internal/contract, реализации под internal/adapter/repo;
  • служба домена — правило, не принадлежащее одной сущности, живёт функцией пакета домена (entity.WorkingStages, entity.SanitizeOriginalFilename);
  • фабрикаentity.NewInProgressResult и соседи: значение приходит согласованным, а не заполняется полями снаружи.

Анемичной модели не заводим. Новое поведение записи ищет дом сначала в домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения наблюдаем: правило о записи, записанное в internal/service условием над её полями, принадлежит internal/entity.

Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища — снято задачей storage-without-pocketbase 2026-08-22. Своя отдача файла и свои маршруты вернули транспорту независимость от инфраструктуры, а узнавание пришедшего приходит ему интерфейсом contract.UserRepository.

Компоненты

Каждый — строкой со ссылкой на capability, а не пересказом её требований.

Компонент Где Что делает
HTTP API internal/controller/http Адреса приложения под корнем /app/ на net/http: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи
Воркеры internal/controller/worker Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен
Сервис расшифровки internal/service Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи
Конвертер и метаданные internal/adapter/{converter,metaviewer}/ffmpeg ffmpeg в ogg/vorbis, ffprobe для длительности
Распознаватель internal/adapter/recognizer/yandex Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем
Репозитории internal/adapter/repo/sqlite Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с RETURNING по пишущему соединению
Файлы записей internal/adapter/repo/sqlite, store.go Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование
Шаги схемы internal/adapter/repo/sqlite/migrations Файл на шаг, версия — число в начале имени; накатывает pressly/goose/v3 под своим замком
Оснастка владельца cmd/devtools Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи
Приложение web/ Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в web/embed/dist и вшивается в бинарник; в git его нет
Раздача приложения internal/controller/http, webapp.go Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика

Цепочка рубежей — uploadednormalizedsubmittedtranscribeddone; рубеж называет достигнутое, а не предстоящее, и нормирует его pipeline, «Рубеж записи называет достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит настройкой.

Внешние границы и форматы

  • Yandex Object Storage. S3-совместимый, клиент aws-sdk-go-v2 с UsePathStyle. Ключ объекта — имя файла, то есть UUID с расширением.
  • Yandex SpeechKit v3. gRPC, stt.api.cloud.yandex.net:443, модель deferred-general, авторизация заголовком Api-Key. Распознавание асинхронное: запрос возвращает идентификатор операции, готовность опрашивается через operation.api.cloud.yandex.net:443, текст читается потоком.
  • ffmpeg и ffprobe. Внешние процессы, ищутся в PATH.
  • SQLite через modernc.org/sqlite. Драйвер на чистом Go: CGO сборке не нужен. База и файлы записей лежат под одним каталогом данных.
  • github.com/pressly/goose/v3. Шаги схемы — библиотекой, а не командной строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте. Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных берём сами.
  • Node и его установщик пакетов. Нужны только сборке приложения и на машину не ставятся: шаг зовёт их контейнером, а образ берёт из ступени Dockerfile. Требованием к машине разработчика поэтому становится docker. Реестр пакетов — сетезависимый адрес набора проверок; все такие перечислены в CLAUDE.md, «Гейт».

Эксплуатация

  • Где работает, что рядом, кто перезапускает: один контейнер на личном сервере, разворачивает и перезапускает Ansible из pet-project-server. Рядом — обратный прокси, который публикует HTTP-порт наружу.

  • Порядок выкладки: конфиг после образа. Прежде здесь стояло правило, разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть. Секцию [telegram] и ключ server.users_while_list человек убирает из боевого файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними сервис поднимает молча.

  • Откат образа на версию до 2026-08-22 не работает вовсе. Каталог данных сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на таком каталоге поднимется, накатит свои шаги в пустое место и заведёт вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага схемы нет и не планируется.

    Прежние два порога — шаги 202608140002 и 202608220001 — этим поглощены: до выкладки record-centric-model откат работал, после перестал, а с уходом встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не потому, что риск сегодня чем-то грозит.

  • Внешние зависимости поимённо и чем каждая отказывает. Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — database.md, «Настройки с числовым значением»:

    Зависимость Падает Отвечает медленно Молчит Отдаёт мусор
    Yandex SpeechKit Шаг возвращает ошибку, запись остаётся на повтор Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции Операция вечно in progress, повтор каждые 5 секунд — до предела простоя в сутки Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж done с пустым перечнем доступных видов текста
    остановка сервиса Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом
    Yandex Object Storage Заливка падает, запись остаётся на рубеже normalized То же, что падение: висит до конца захвата SpeechKit не прочитает объект и вернёт отказ операции
    ffmpeg, ffprobe Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит Конвейер стоит: вызов синхронный Выходной файл пуст, отказ вылезет на распознавании
    База (файл на диске) Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору
    Диск Запись файла падает, задача не заводится
  • Кто заметит отказ и когда: тот, кто загрузил запись, — карточкой записи: остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике transcriber_worker_job_count с меткой error="true", и метка stage называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров имя потока перестало что-либо значить, а разрез по шагу — единственное, чем «падает приведение» отличается от «падает распознавание». Плюс логи контейнера. Отдельного оповещения нет.

  • Журнал событий записи — второй канал наблюдения, record_events. Пишется на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.

  • Характер потока: непрерывный, но разреженный. Воркеры опрашивают базу вхолостую с паузой из database.md, «Настройки с числовым значением».

Единые точки проекта

Что Где
Приём аудио и заведение записи TranscribeService.createRecord — единственный путь, которым запись появляется в хранилище
Возврат остановленной записи в работу cmd/devtools resume — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет
Выдача идентификатора строки internal/ident — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же
Подключение к базе internal/adapter/repo/sqlite.Open — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих
Накат схемы internal/adapter/repo/sqlite.Migrate — до подъёма входов и до старта воркеров, под замком каталога данных
Раскладка файлов записи internal/adapter/repo/sqlite.Store — подкаталог на запись; путь на диске за её пределы не выходит
Захват записи воркером AudioRecordRepository.FindAndAcquire — один запрос с RETURNING, отдаёт идентификатор и признак захвата
Объявление рубежа internal/entity/stage.go — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда
Выбор шага по рубежу TranscribeService.stepFor — таблица, а не привязка к воркеру
Рабочая копия файла на диске FileRepository.Localize, Stage, StageEmpty — они же дают единственный способ её убрать (WorkFile.Close); зовёт его шаг
Переход записи на рубеж entity.AudioRecord.MoveToState — чистит служебные поля прошлого рубежа и ставит время входа
Откладывание работы entity.AudioRecord.Postpone — ставит паузу и снимает захват, рубежа не трогая
Остановка и перезапуск entity.AudioRecord.Halt и Resume; запись причины и события — TranscribeService.halt, одно место на все причины
Разбор конфигурации internal/config.LoadConfig
Чтение времени internal/clockNow даёт метку в UTC, Start — начало измерения длительности; time.Now вне пакета запрещён правилом линтера
Метрики internal/metrics, префикс имени transcriber_
Значения метки формата internal/metrics.FormatLabel — приводит расширение к закрытому перечню, прочее заменяет на other; нормирует спека intake
Отображение доменной ошибки в ответ internal/controller/http.mapDomainError — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой OneErrorForm, стоящий снаружи всех прочих
Состояния отбора списка internal/entity.ListFilter вместе с WorkingStages и TerminalStages — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса
Уборка имени файла отправителя internal/entity.SanitizeOriginalFilename — режет по пределу и убирает управляющие знаки; зовёт её приём
Адресное пространство сервиса internal/controller/http.ServiceMounts — перечень корней и адресов наблюдения. Он порождает регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания
Узнавание предъявителя sqlite.UserRepository.EnsureUser — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом contract.UserRepositoryinternal/controller/http.TrustedHeaderIdentity
Приём значения заголовка internal/entity.AcceptProviderLogin, AcceptDisplayName, AcceptEmail — правило одно на все способы представиться
Ограничитель частоты internal/controller/http.RateLimit — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса

Единых точек, которых нет и которые ожидались бы, сегодня не осталось. Время ушло из перечня отсутствий 2026-08-13 — его читает internal/clock, и запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей json-api-for-spa, и до неё обработчик решал сам: опрос отвечал 404 на упавшую базу, а приём — 500 на негодный файл; выдача идентификаторов — 2026-08-22 задачей storage-without-pocketbase, и до неё их выдавало встроенное хранилище своим алфавитом, а сервис звал uuid.NewString() по месту.

Деплой

Образ собирается по контракту роли app_image: task image даёт transcriber:$BUILD_ID, по умолчанию transcriber:dev. Реестр не участвует — образ едет на сервер через docker save/load. Выкладку целиком запускает человек командой inv pl -- transcriber из pet-project-server.

Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение собирается первым — вшивание требует готового каталога, — а в рабочий слой Node не попадает. Финальный слой — alpine с ca-certificates и ffmpeg, процесс работает под непривилегированным пользователем transcriber — в образе он назван числом, USER 1000:1000, а не именем: имя разрешает в идентификатор сам образ, и хост, которому надо понять владельца файлов в смонтированном каталоге, разрешить его не может. Числа те же, что при заведении пользователя.

Ступень бинарника собирает одну точку входа — ./cmd/transcriber, а не весь пакет: рядом в cmd/ живёт devtools, оснастка разработчика, и в образе ей делать нечего.

Оснастка лежит одним пакетом с подкомандами, а не пакетом на инструмент, и это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки здесь, «Деплой» в этом файле, «Команды» в памятке, README, — и забытая строка сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти четыре места однажды, сколько бы подкоманд в нём ни завелось.

Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес: у musl запрос имени идёт A и AAAA разом и ждёт оба ответа, поэтому DNS-сервер, молчащий на AAAA, оставляет установщика пакетов без адреса при живом A. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет, а висит — исход хуже красного. Слои этой ступени в рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.

По весу финальный образ от ступени приложения не растёт вовсе: она отдаёт следующей только собранное, а сама в рабочий слой не копируется. Вшитое приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не замерялось и замеряться не будет — решение владельца от 2026-08-15.

Открытые вопросы

  • Учётные записи. Кто пришёл, сервис узнаёт заголовком, который ставит обратный прокси, сходив к Authelia; учётная запись заводится первым обращением и находится по логину у провайдера. Задача trusted-header-login 2026-08-22. Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа судит провайдер на каждом запросе, а не однажды выданное значение. Норма — access, решение — ADR-2026-08-22-login-by-trusted-header. Не решено одно: как связать чат Telegram с учётной записью — от этого зависит возвращение убранного входа. Второго периметра на порту сервиса при этом не осталось: панель администратора ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси больше нечего.
  • Приложение. Каркас поставлен spa-skeleton 2026-08-15: приложение открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и списка нет — их делают upload-and-status-screen и records-list-screen. Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и сборкой Vite — 2026-08-11, ADR, сравнение кандидатов в research/spa-framework.md. Тем же решением Node входит в гейт и слоем в сборку образа; как именно он зовётся — решением ADR 2026-08-15. Не решено, брать ли готовый набор компонентов.
  • Уведомления. Пользователь веба узнаёт о готовности только опросом карточки. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра security.md.
  • Долгие записи. Потолок сегодня неизвестен и не замерялся: ограничения deferred-general по длине не выяснены. Расчётные шесть часов нормирует storage, «Файл записи живёт в хранилище»; откуда взято число — research/pocketbase-defaults.md, «Чего эта записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой она осталась только этим числом.
  • Приём большого файла. Форма читается целиком, предел памяти под multipart задан числом в database.md, «Настройки с числовым значением»; обрыв начинает загрузку заново. Загрузку частями разбирает разведка chunked-upload-choice; её выбор меняет публичный контракт приёма и потому идёт через решение в adr/.
  • Учёт расхода. Распознавание и языковая модель оплачиваются по факту, а учёта по пользователям нет: метрики считают сервис целиком. Что именно копится — записи о потреблении или счётчики — решает задача usage-accounting.
  • Срок хранения. Записи и тексты решено хранить бессрочно (паспорт, 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
  • Резервные копии. Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
  • Формат для распознавания. Конвертер отдаёт ogg/vorbis (libvorbis), а SpeechKit получает ContainerAudio_OGG_OPUS. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом.
  • Видео. Дорожка из видеофайла к приёму допускается — расширение он берёт из имени и о годности содержимого спрашивает источник метаданных, — но конвертер на этом случае не проверялся.
  • Очередь. Модель очереди сделана задачей pocketbase-storage 2026-08-12 (ADR), перестроена вокруг аудиозаписи задачей record-centric-model 2026-08-14 и нормирована спекой pipeline. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер (research/job-queue.md, «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. Хранилище при этом сменилось задачей storage-without-pocketbase 2026-08-22 (ADR), а модель очереди пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
  • Наблюдаемость. /metrics остаётся и развивается. Чем — дописывать счётчики через client_golang или перейти на OpenTelemetry с трассировкой — решает разведка opentelemetry-fit. Коллектор был бы процессом, которого в выкладке сегодня нет.
  • Выводы из текста. Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра security.md. Не решено, отдельный это шаг конвейера или продолжение шага распознавания.