# Архитектура

Обзор: как сложено и где что работает. **Поведение системы здесь не описывается**
— нормативно оно живёт в `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;
- [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, а не пересказом её требований.

<!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->

| Компонент | Где | Что делает |
| --- | --- | --- |
| 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), «Владелец видит записи в панели» |

Цепочка рубежей — `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`.

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

- **Где работает, что рядом, кто перезапускает:** один контейнер на личном
  сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
  обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
  разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
  целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
  Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
  файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
  сервис поднимает молча.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
  этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
  те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
  поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
  чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
  двух бинарей на одном каталоге данных.

  Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
  этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
  обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
  выкладки `record-centric-model` откат образа работает, после — нет.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
  медленно» читается вместе с тем, что таймаута нет ни у одного обращения
  наружу — [database.md](database.md), «Настройки с числовым значением»:

  <!-- канон: поведение → openspec/specs/intake, pipeline -->

  | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
  | --- | --- | --- | --- | --- |
  | 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` — режет по пределу и убирает управляющие знаки; зовёт её приём |

Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `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`.

Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
процесс работает под непривилегированным пользователем `transcriber`.

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

- **Учётные записи.** Вход через 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 не закрывает: у неё свой пароль
  суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать 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
  входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
  обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
  компонентов.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
  Доставку решено брать внешнюю — 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). Не
  решено, отдельный это шаг конвейера или продолжение шага распознавания.
