- один факт — один дом: рецепт локального входа, правило чтения X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к своим домам, копии заменены ссылками - форма [auth.test_headers] выровнена по образцу конфига в семи местах; сценарии intake и archive перестали ссылаться на сессию, которой сервис не выдаёт - поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку адреса пира зовут трое, а не двое; обзор capability access знает о задаче 2026-08-23
51 KiB
Архитектура
Обзор: как сложено и где что работает. Поведение системы здесь не описывается
— нормативно оно живёт в 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-log2026-08-11,pocketbase-storageиoidc-login2026-08-12,local-run-without-telegram-token2026-08-13,remove-telegram-intake2026-08-14,storage-without-pocketbase2026-08-22; - pipeline — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
errors-as-instead-of-typecast2026-08-11,pocketbase-storage2026-08-12,local-run-without-telegram-token2026-08-13,remove-telegram-intake2026-08-14 иstorage-without-pocketbase2026-08-22. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет разделPurposeсамой спеки; - storage — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задачи
pocketbase-storage2026-08-12 иstorage-without-pocketbase2026-08-22. Последняя убрала встроенное хранилище целиком: база стала своей, файлы — своим каталогом, панель владельца исчезла и не заменена ничем; - recognition — попытка распознавания
у внешнего провайдера: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача
record-centric-model2026-08-14; - archive — архив своих записей глазами
приложения: пространство адресов
/app/и единая форма отказа с машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само чтение — страница записей ключом, карточка без текста и текст названного вида. Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задачаjson-api-for-spa2026-08-15; - webapp — приложение в браузере: чем
сервис его отдаёт, каким адресом оно открывается, что делает обновление
страницы посреди него и что человек видит, открыв его. Здесь же правило
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
ответов и то, что раздача пишет в журнал. Задача
spa-skeleton2026-08-15; - access — кто пришёл в сервис и пускают ли
его дальше: узнавание по заголовку доверенного источника, заведение учётной
записи первым обращением и то, какие адреса остаются открытыми. Собственный
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
trusted-header-login— вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. Задачиrecord-ownershipиremove-telegram-intake2026-08-14. Здесь же изъятие отладочного запуска: при включённом предохранителе[server] debugзаголовки входа подставляет сам сервис значениями из[auth.test_headers]— с отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задачаconfig-test-headers-login2026-08-23; решения — ADR-2026-08-23-test-headers-substituted-by-service и ADR-2026-08-23-no-address-guard-for-debug-login.
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability.
Принципы
- Один процесс. HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно.
- Очередь таблицей. Состояние задачи лежит таблицей базы; неделимость захвата и порядок выборки нормирует pipeline, «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, ADR, сравнение кандидатов в research/job-queue.md. Решение пережило уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него одно слово — таблица перестала быть коллекцией.
- Шаг конвейера идемпотентен по повтору. Что делает срок захвата и когда задача возвращается в работу, нормирует pipeline, «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения.
- Подставной собеседник в боевом бинарнике объявлен своим ключом. Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция
[auth.test_headers], подмену распознавания — правка кода (internal/adapter/recognizer/memory.go). Ключ, названный общим словом, обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт себя как в бою». Предохранитель[server] debugвторым именем собеседнику при этом не служит и правилу не противоречит: собой он не называет ничего, а держит закрытый перечень следствий, и перечень этот ведёт спека access, «Предохранитель отладки включает только подстановку заголовков». Новое следствие вешается на ключ только новым требованием той же спеки. - Чистая архитектура. Зависимости направлены внутрь, к домену: внутренний
слой не знает внешнего никогда.
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: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует access, «Отладочный запуск называет пришедшего настройками» |
| Воркеры | 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 |
Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
Цепочка рубежей — uploaded → normalized → submitted → transcribed →
done; рубеж называет достигнутое, а не предстоящее, и нормирует его
pipeline, «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
Внешние границы и форматы
- Yandex Object Storage. S3-совместимый, клиент
aws-sdk-go-v2сUsePathStyle. Ключ объекта — имя файла записи, то есть её идентификатор с расширением; идентификаторы строитinternal/identи они ULID, а не 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/BurntSushi/toml. Формат единственного источника настроек. Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча отбрасывает — наблюдение и чем оно проверено, в research/toml-unknown-keys.md.github.com/pressly/goose/v3. Шаги схемы — библиотекой, а не командной строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте. Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных берём сами.- Node и его установщик пакетов. Нужны только сборке приложения и на машину
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени
Dockerfile. Требованием к машине разработчика поэтому становится docker. Реестр пакетов — сетезависимый адрес набора проверок; все такие перечислены в CLAUDE.md, «Гейт».
Эксплуатация
-
Где работает, что рядом, кто перезапускает: один контейнер на личном сервере, разворачивает и перезапускает Ansible из
pet-project-server. Рядом — обратный прокси, который публикует HTTP-порт наружу. -
Порядок выкладки: конфиг после образа. Прежде здесь стояло правило, разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть. Секцию
[telegram]и ключserver.users_while_listчеловек убирает из боевого файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними сервис поднимает молча. Чем это обеспечено и как проверено — research/toml-unknown-keys.md; тем же свойством безопасно и обратное направление: прежний образ поднимается на конфиге с ключами, которых он ещё не знает. -
Откат образа на версию до 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/clock — Now даёт метку в 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.UserRepository — internal/controller/http.TrustedHeaderIdentity |
| Приём значения заголовка | internal/entity.AcceptProviderLogin, AcceptDisplayName, AcceptEmail — правило одно на все способы представиться |
| Имена заголовков входа | internal/controller/http.IdentityHeaderNames вместе с константами рядом — тройка Remote-* перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией [auth.test_headers]; что делает старт с ключом вне набора, нормирует access, «Настройка, открывающая вход всем, роняет старт» |
| Сверка адреса пира с перечнем доверенных | internal/controller/http, identity.go — peerAddress и isTrusted. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Ограничитель частоты | 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-login2026-08-22. Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа судит провайдер на каждом запросе, а не однажды выданное значение. Норма — access, решение — ADR-2026-08-22-login-by-trusted-header. Не решено одно: как связать чат Telegram с учётной записью — от этого зависит возвращение убранного входа. Второго периметра на порту сервиса при этом не осталось: панель администратора ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси больше нечего. - Приложение. Каркас поставлен
spa-skeleton2026-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-storage2026-08-12 (ADR), перестроена вокруг аудиозаписи задачейrecord-centric-model2026-08-14 и нормирована спекойpipeline. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер (research/job-queue.md, «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. Хранилище при этом сменилось задачейstorage-without-pocketbase2026-08-22 (ADR), а модель очереди пережила смену: отменилось одно слово — таблица перестала быть коллекцией. - Наблюдаемость.
/metricsостаётся и развивается. Чем — дописывать счётчики черезclient_golangили перейти на OpenTelemetry с трассировкой — решает разведкаopentelemetry-fit. Коллектор был бы процессом, которого в выкладке сегодня нет. - Выводы из текста. Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра security.md. Не решено, отдельный это шаг конвейера или продолжение шага распознавания.