# CLAUDE.md Памятка для работы над transcriber. Перед задачей прочитай также [docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md) и [docs/conventions/](docs/conventions/README.md). Проект ведём по-русски. ## Что это Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи. Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом рядом с базой. Панели администратора у сервиса нет: встроенное хранилище, дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до задачи, которая свяжет чат с учётной записью. Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не заводит, с живым потоком не работает и складом произвольных файлов не служит. Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив. Перечень выше — выжимка, границу домена целиком держит [docs/passport.md](docs/passport.md). ## Стек Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через `modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`, маршруты и слои на `net/http`, файлы записей своим каталогом, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3 с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют его Biome и юнит-тесты Vue. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. ## Проектирование **Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а реализацию подставляет точка входа. Направления держат тесты-сканеры `internal/archrules`, а не договорённость. **Модель домена ведётся тактическими шаблонами DDD** — сущность и корень агрегата, объект-значение, доменное событие, репозиторий, служба домена, фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а прикладной слой назначает порядок шагов, а не правила. Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня — [docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с ним молча. ## Инварианты Что нарушать нельзя. - **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object Storage не попадают в git, в лог, в ответ пользователю и в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки. **critical** Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с самим секретом: вход переехал на доверенный заголовок, обменивать код стало не на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью. - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла пользователя и его сообщение в лог не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. **critical** *Изъятие:* расширение — хвост после последней точки — в журнал попадает собственным полем: по нему прослеживается путь записи. Имени файла в журнале нет вовсе (инвариант ниже). Изъятие узкое и кончается журналом: наружу, меткой метрики, расширение выходит только приведённым к перечню известных форматов. Границу держит спека `intake`, цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), остаток — [docs/security.md](docs/security.md). - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет запись пригодной к повтору, либо ставит на неё признак остановки с причиной — и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса журналом. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком переехала на карточку. **major** - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым файлом шага. Необратимо: учёт применённого ведёт сама база. **critical** *Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина — стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого листа, а новая база ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний. - **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся идентификатором записи, файл — идентификатором с расширением. В журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. **critical** - **Колонки записи правятся в трёх местах** пакета хранилища — `writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и `rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из них, теряется молча — запись сохранится без поля, приедет с нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот ноль поверх сохранённого. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор и признак своего захвата, и перечень перестал расти с моделью. Отображение при этом идёт **по имени колонки**: именованные параметры запроса и место назначения, найденное по имени, — позиционный список дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила `internal/archrules`. **major** - **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту ниже не пишется в журнал и не считается в метрику: запись встанет без единого следа. Сверку держат правила `internal/archrules`. **major** - **Результат пишет только держатель захвата, и держатель узнаётся значением.** Признак захвата уникален для каждого захвата, и запись результата условна по нему, а не по занятости записи. Шаг, чей захват за время работы достался другому — по протуханию срока или после того, как человек вернул запись в работу подкомандой оснастки, — завершается без записи результата. Условие по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись по очереди, портя её результат. **major** - **У записи есть владелец, и колонка пустого значения не принимает.** Ничья запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и держит это схема, а не договорённость: колонка объявлена внешним ключом на учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью запись заводили руками мимо него, она уходила в конвейер, стоила денег на распознавание и не доставалась потом никому. Правило со стороны спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной записью, потому что схема запрещает **заводить** ничью, а это правило — **спрашивать** ничьим именем. **major** - **Остановленная запись несёт причину, какой бы та ни была.** Причин три — приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит исход «пригодна к повтору» исключён, и другого следа у неё не будет. Обязанность, записанная у одной причины, у остальных читалась бы как снятая. **major** ## Команды ```bash go build ./... # CGO не нужен go test ./... # в гейте идёт с -race, и там нужен компилятор C go vet ./... gofmt -l . golangci-lint run go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml go run ./cmd/devtools resume -c config.toml # вернуть остановленную запись в работу task front # приложение: зависимости, Biome, юнит-тесты, сборка task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом ``` Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому становится docker — тот же, которым собирается образ. Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` — скопируй `config.example.toml` и заполни; известные прорехи образца перечислены в [docs/conventions/config.md](docs/conventions/config.md) строками «*Расхождение:*». ## Гейт - **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию `origin/master`; переопределяется `task gate BASE=`. - **Какое правило чем проверяется** — конвенция [docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь семантика гейта, там перечень правил, подавлений и место настройки каждого; перечень здесь не повторяется. - **Где логи шагов:** вывод команды, отдельного файла нет. - **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У `docs.py check`, `tasks.py check`, `openspec.py check` и `scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая `tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент — отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без которых не работает детектор гонок — тесты он в этом случае всё равно гоняет, без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 — переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`, `golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой». Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на любой отказ шага выходит с 201, а код шага печатает строкой («exit status 3»), поэтому словарь читается по коду скрипта. - **Что красит безусловно и почему:** красная сборка приложения, находка Biome, красный юнит-тест приложения, отказ сборки, тестов, `go vet`, гонка, найденная детектором (`go test -race`), переписанный применённый шаг схемы, неотформатированный файл, находка `golangci-lint`, расхождение объявленных версий Go, дрейф раскладки документов, дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с именем недостающего плагина, а не пропускается молча. - **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на **затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`, `gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком, сверка версий Go, сверки документов и `govulncheck`: они смотрят всё дерево либо требуют сети, а pre-commit обязан быть быстрым. - **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3, отдельно от красной сборки, у которой код 1; полный кэш установщика снимает поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а гейт, который висит, хуже красного. - **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем, шаг не роняет. Такая сегодня одна — `GO-2026-5932` в `golang.org/x/crypto/openpgp`, исправления у неё нет вовсе. - **Чего в гейте намеренно нет и кто тогда обязан это гонять:** - **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за которого образ перестаёт собираться, но собираемости не проверяет. Собрать образ по-прежнему может только человек — `task image`, и на подъёме версии это обязательно; - собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и `DL3018` по существу (alpine не держит старые версии пакетов, закрепление ломает сборку через недели). Причины стоят строками в `Taskfile.yml`; - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс коммита. Полную историю никто не проверяет; - согласованность документов между собой и с кодом — её судят агенты, зовёт их скилл `av-dev:doc-healthcheck`, и звать его надо руками; - покрытие изменённых строк не считается ничем. **Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.** Красный шаг означает поломку — свою или чужую, но поломку, а не наследство. Списывать отказ на долг больше нельзя: списывать не на что. Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как новый: - `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`, а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`. Владельца файлов в смонтированном каталоге это не двигает, потому что те же числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`); - `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два сравнения ошибок приведением типа. Закрыто задачей `errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена настройка `check-blank`, поэтому `_ = x.Close()` больше не снимает замечание: отказ, который решено не проверять, объявляют в `exclude-functions` поимённо; - `go test ./...` чинила задача `http-handler-tests-never-green`. ## Запреты - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и база (`data/transcriber.db`), и записи живых людей (`data/records/<запись>/`). На стройке под ним пусто и сервис остановлен — запрет от этого не снимается: каталог принадлежит серверу, и выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. - **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]` проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют `internal/adapter/recognizer/memory.go`. Подробности строками в `config.example.toml`. **На машине без прокси представиться нечем**: сервис узнаёт пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков не ставит. Заголовок подставляет сам сервис — настройками, а не вторым процессом: рецепт из трёх правок записан связным блоком в `config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых адресов в перечень, `[server] debug = true`, раскомментированная секция `[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по адресу сервиса, второго порта нет. Заполненная имитация при выключенном предохранителе роняет старт с именем ключа. Ключей боевого провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. - **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server` запускает человек. - **Проверок над проверками не заводить.** Уровень проверки один: линтеры и тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты на шаги гейта и на свои скрипты проверок, стражи предмета у правил, механизация покрытия изменённого кода, мутационная сверка оракулов и требование мутировать тест, чтобы убедиться в его способности упасть. Решение владельца 2026-08-13; им закрыты четыре задачи — причины и даты в [tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать сценариев шага сверки версий Go, единственный такой файл в проекте. Исключений у запрета нет. - **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во временном каталоге и убирают за собой. - **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не писать: этот каталог смонтирован на сервере. ## Работа - **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в [tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное решение человека; боевой каталог данных остаётся под запретом; выкладку по-прежнему запускает человек. - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Сообщение коммита** без трейлера `Co-Authored-By`. - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация секрета. - **Что считается сломанным** — новый красный шаг гейта, которого не было до твоей правки. Такое чинится прежде любой другой работы. Исключений из этого правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать красный шаг больше не на что. - **Ориентир по размеру порции:** не замерялся. - **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены поимённо. ## Язык - Документация, комментарии, сообщения коммитов — русский. - Код и идентификаторы — английский. - Текст, который видит пользователь сервиса, — русский. - **Точного числа накопленного в документах нет.** «Три capability», «пять прогонов ревью», «две типизированные ошибки» расходятся с действительностью на первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина такое не считает, а читатель верит написанному. Ссылаться можно только на **конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом** («заведённые capability», «записи журнала ниже»). Само перечисление при этом законно: перечень обновляют вместе с предметом, а число живёт отдельно от него и потому протухает в одиночку. *Изъятие:* число, которое не растёт с работой, остаётся числом — количество уровней журнала в библиотеке, ступеней сборки образа, состояний списка на экране. Так же законно **историческое** число в записи о прошлом: «решением от 2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт. Настройки с числовым значением — свой случай, их дом [docs/database.md](docs/database.md).