# Архитектура Обзор: как сложено и где что работает. **Поведение системы здесь не описывается** — нормативно оно живёт в `openspec/specs/`. Места, где оно всё-таки описано, помечены маркером долга и переезжают туда первой же задачей, которая их трогает. Документ описывает **сегодняшнее** устройство. Куда проект идёт — в [passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из этого ещё не решено — в разделе «Открытые вопросы». Заведённые capability нормируют **поведение сервиса** для его потребителей — все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе: у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением от 2026-08-13 его нормы живут в самих шагах, их проверках и [conventions/go-linters.md](conventions/go-linters.md). - [intake](../openspec/specs/intake/spec.md) — **приём по 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; - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват задачи и срок его протухания, число попыток, остановка признаком, пауза перед повтором и молчание конвейера наружу: задачи `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. Переходы состояний и отмена контекста посреди шага остаются долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` 2026-08-12; - [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется целиком и вложением, как из сохранённого строится структура реплик без повторной оплаты и почему разбор формата провайдера не доходит до конвейера. Задача `record-centric-model` 2026-08-14; - [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами приложения**: пространство адресов `/app/` и единая форма отказа с машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само чтение — страница записей ключом, карточка без текста и текст названного вида. Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa` 2026-08-15; - [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем сервис его отдаёт, каким адресом оно открывается, что делает обновление страницы посреди него и что человек видит, открыв его. Здесь же правило неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15; - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` 2026-08-12. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability. ## Принципы - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость захвата и порядок выборки нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим». Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение кандидатов в [research/job-queue.md](research/job-queue.md). - **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда задача возвращается в работу, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только `internal/contract`; ffmpeg, Yandex и хранилище подставляются в `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть роутер этого хранилища, а не наш сервер поверх него. Правила на это направление нет намеренно. ## Компоненты Каждый — строкой со ссылкой на capability, а не пересказом её требований. | Компонент | Где | Что делает | | --- | --- | --- | | HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | | Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | | Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет | | Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика | Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` → `done`; рубеж называет достигнутое, а не предстоящее, и нормирует его [pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит настройкой. ## Внешние границы и форматы - **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`. - **Node и его установщик пакетов.** Нужны только сборке приложения и на машину не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому становится docker. Реестр пакетов — сетезависимый адрес набора проверок; все такие перечислены в [CLAUDE.md](../CLAUDE.md), «Гейт». ## Эксплуатация - **Где работает, что рядом, кто перезапускает:** один контейнер на личном сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — обратный прокси, который публикует HTTP-порт наружу. - **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило, разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть. Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними сервис поднимает молча. - **Откат образа через шаг схемы `202608140002` не работает и не говорит об этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном двух бинарей на одном каталоге данных. Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа; обратного шага схемы нет и не планируется. Порог перехода назван прямо: до выкладки `record-centric-model` откат образа работает, после — нет. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](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](database.md), «Настройки с числовым значением». ## Единые точки проекта | Что | Где | | --- | --- | | Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище | | Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Захват записи воркером | `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` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала | Единых точек, которых **нет** и которые ожидались бы: идентификаторы генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло 2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл. ## Деплой Образ собирается по контракту роли `app_image`: `task image` даёт `transcriber:$BUILD_ID`, по умолчанию `transcriber:dev`. Реестр не участвует — образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой `inv pl -- transcriber` из `pet-project-server`. Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение собирается первым — вшивание требует готового каталога, — а в рабочий слой Node не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс работает под непривилегированным пользователем `transcriber`. **По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт следующей только собранное, а сама в рабочий слой не копируется. Вшитое приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не замерялось и замеряться не будет — решение владельца от 2026-08-15. ## Открытые вопросы - **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер — Authelia, ответ провайдера обрабатывает PocketBase, а не наш код ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия живёт кукой `transcriber_session` и сама себя не продлевает. Норма — [access](../openspec/specs/access/spec.md), решения — [ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md) и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md). **Не решено одно:** как связать чат Telegram с учётной записью — от этого зависит возвращение убранного входа. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и списка нет — их делают `upload-and-status-screen` и `records-list-screen`. Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и сборкой Vite — 2026-08-11, [ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в [research/spa-framework.md](research/spa-framework.md). Тем же решением Node входит в гейт и слоем в сборку образа; как именно он зовётся — решением [ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15. Не решено, брать ли готовый набор компонентов. - **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки. Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и текст расшифровки начинает уходить на сторону — сдвиг периметра [security.md](security.md). - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения `deferred-general` по длине не выяснены. Расчётные шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл записи живёт в хранилище»; откуда взято число — [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта записка не узнала». - **Приём большого файла.** Форма читается целиком, предел памяти под multipart задан числом в [database.md](database.md), «Настройки с числовым значением»; обрыв начинает загрузку заново. Загрузку частями разбирает разведка `chunked-upload-choice`; её выбор меняет публичный контракт приёма и потому идёт через решение в `adr/`. - **Учёт расхода.** Распознавание и языковая модель оплачиваются по факту, а учёта по пользователям нет: метрики считают сервис целиком. Что именно копится — записи о потреблении или счётчики — решает задача `usage-accounting`. - **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт, 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается. - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или приложению нужна команда выгрузки: база под нагрузкой копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его или нет, тоже не решено. - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли сервис определяет содержимое сам, то ли часть записей теряется на этом. - **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из имени и о годности содержимого спрашивает источник метаданных, — но конвертер на этом случае не проверялся. - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер ([research/job-queue.md](research/job-queue.md), «Как снималось»), — при нагрузке в единицы записей в день, и во что это обходится, никто не мерил. - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в выкладке сегодня нет. - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не решено, отдельный это шаг конвейера или продолжение шага распознавания.