# CLAUDE.md Памятка для работы над transcriber. Перед задачей прочитай также [docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md) и [docs/conventions/](docs/conventions/README.md). Проект ведём по-русски. ## Что это Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу панель администратора. Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст руками не правит и в форматы документов не экспортирует, учётных записей не заводит, с живым потоком не работает и складом произвольных файлов не служит. Записи и расшифровки хранит бессрочно: решением от 2026-08-11 сервис — архив. Перечень выше — выжимка, границу домена целиком держит [docs/passport.md](docs/passport.md). ## Стек Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. ## Инварианты Что нарушать нельзя. - **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки. **critical** *Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции пользователей хранилища — туда его кладёт приведение настроек при каждом подъёме, потому что применённый шаг схемы не переписывается и не пережил бы ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные места запрета это не отменяет. - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла пользователя и его сообщение в лог не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. **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). - **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но чужие записи к тому моменту уже обработаны за наши деньги. **critical** - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет задачу пригодной к повтору, либо переводит её в `failed` и сообщает пользователю. Молчаливый выход из шага без записи в лог и без смены состояния запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. **major** - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не логируется, не считается в метрику и не поднимает уровень. Нарушение даёт запись раз в секунду на каждый воркер. **major** - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым файлом шага. Необратимо: хранилище считает применённое по имени файла. **critical** - **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание PocketBase строит имя из имени, данного отправителем, — оно не применяется. Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. **critical** - **Колонки очереди правятся в четырёх местах** пакета хранилища — `applyToRecord`, `recordToJob`, константа `acquireColumns` и структура `acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них. Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения: поле теряется **только у задачи, попавшей к воркеру**. **major** - **Результат пишет только держатель захвата.** Шаг, чей захват за время работы достался другому, завершается без записи и без ответа отправителю. Иначе два воркера пишут в одну задачу по очереди, а отправитель получает два ответа. **major** ## Команды ```bash go build ./... # CGO не нужен go test ./... # в гейте идёт с -race, и там нужен компилятор C go vet ./... gofmt -l . golangci-lint run go run . -c config.toml # флаг -c или --config, по умолчанию config.toml task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом ``` Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` — скопируй `config.dist.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»), поэтому словарь читается по коду скрипта. - **Что красит безусловно и почему:** отказ сборки, тестов, `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 обязан быть быстрым. - **Шагу `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` сегодня зелёный целиком, и объявленных долгов у него нет.** Красный шаг означает поломку — свою или чужую, но поломку, а не наследство. Списывать отказ на долг больше нельзя: списывать не на что. Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как новый: - `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/data.db`), и записи живых людей (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его ронять и пересоздавать можно свободно. - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном перехватывает обновления у работающего, и пользователь теряет ответы. Запускай с **пустым** `telegram.bot_token`: сервис поднимается без Telegram и работает одним входом, по HTTP. Пустого токена для подъёма мало — секции `[auth]` и `[yandex]` проверяются на старте, но наружу при этом не ходят, так что годятся выдуманные непустые значения; подробности строками в `config.dist.toml`. - **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/` временное не писать: этот каталог смонтирован на сервере. ## Работа - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Сообщение коммита** без трейлера `Co-Authored-By`. - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация секрета. - **Что считается сломанным** — новый красный шаг гейта, которого не было до твоей правки. Такое чинится прежде любой другой работы. Исключений из этого правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать красный шаг больше не на что. - **Ориентир по размеру порции:** не замерялся. - **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены поимённо. ## Язык - Документация, комментарии, сообщения коммитов — русский. - Код и идентификаторы — английский. - Текст, который видит пользователь Telegram, — русский.