## Context

Два признака конвейера — «работы в этом состоянии нет» и «подходящей задачи не
нашлось» — сегодня узнаются приведением значения ошибки к точному типу:
`err.(*contract.NoopJobError)` в `internal/controller/worker/worker.go:51` и
`err.(*contract.JobNotFoundError)` в `internal/service/transcribe.go:392`.
Приведение видит только само значение и слепо к пояснениям, добавленным
обёрткой `fmt.Errorf("…: %w", err)`.

Путь сегодня короткий и обёрток на нём нет: `FindAndAcquire`
(`internal/adapter/repo/sqlite/transcript_job_repo.go:177`) рождает
`JobNotFoundError`, `findJob` переводит его в `NoopJobError`, воркер этот признак
ловит. Поэтому дефект пока не проявился — он ждёт первой обёртки, а обёртка
`%w` объявлена конвенцией `docs/conventions/errors.md` умолчанием проекта. То
есть код написан против собственного умолчания, и цена срабатывания —
три записи отказа в секунду и столько же засчитанных сбоев, которых не было.

Отдельно и мельче: отказ закрытия соединения теряется в двух местах —
`sttConn.Close()` на пути отказа конструктора распознавателя
(`internal/adapter/recognizer/yandex/speechkit.go:55`) и `defer
recognizer.Close()` при остановке процесса (`main.go:124`).

Оба сюжета держат гейт проекта красным четырьмя замечаниями линтера; долг
объявлен в `CLAUDE.md`, разделе «Гейт».

## Goals / Non-Goals

**Goals:**

- Признак пустого прогона переживает пояснение, добавленное на любом
  промежуточном шаге.
- Отказ закрытия соединения не теряется молча.
- Гейт зелёный целиком: замечаний `errorlint` и `errcheck` нет.

**Non-Goals:**

- Внешнее поведение не меняется: ни ответы пользователю, ни набор состояний
  задачи, ни формат метрик.
- Единая точка перевода доменной ошибки в HTTP-статус — другое расхождение той
  же конвенции, записанное там же; этим изменением не трогается.
- Обёртки `%w` по всему пути не расставляются: изменение делает проверку
  устойчивой к ним, а не вводит их.
- `tg.EmptyBotTokenError` — третья типизированная ошибка без полей — не трогается:
  линтер на ней молчит, приведения типа у неё нет.

## Decisions

### Решение 1: признак узнаётся `errors.As`, типы остаются

Обе проверки переходят на `errors.As` с сохранением сегодняшних типов
`contract.NoopJobError` и `contract.JobNotFoundError`.

Что человек увидит иначе: ничего — ровно в этом ценность. Владелец сервиса
увидит разницу лишь в тот день, когда кто-то добавит пояснение к ошибке на этом
пути: журнал останется тихим, вместо того чтобы наполниться отказами, которых
не было.

**Рассмотрено и отвергнуто — sentinel вместо типов.** Конвенция
(`docs/conventions/errors.md`, раздел «Sentinel и типизированные») говорит:
типизированная ошибка нужна, когда вызывающему нужны **данные**, а данные обоих
типов сегодня не читает никто — обе проверяются на факт. По этому доводу типы
следовало бы заменить на `errors.New` и проверять `errors.Is`, а состояние
задачи вносить обёрткой `fmt.Errorf("%s: %w", state, contract.ErrNoJob)`.

Отвергнуто по цене против цели: обе формы **одинаково** устойчивы к обёртке, то
есть по цели изменения они неразличимы, а sentinel правит пять мест вместо двух
и переписывает рождение ошибки в репозитории и в сервисе. Задача — снятие долга
линтера, а не пересмотр номенклатуры ошибок. Довод конвенции при этом не
исчезает: он остаётся верным и становится поводом отдельной работы в тот день,
когда типы так и не начнут нести читаемых данных.

**Рассмотрено и отвергнуто — оставить приведение, заглушив линтер.** Правило
`errorlint` выключается директивой на строке. Отвергнуто: дефект от этого не
исчезает, а перестаёт быть видимым, и следующий читатель кода примет молчание
линтера за проверенность.

### Решение 2: отказ закрытия — по месту, а не единым правилом

Два места разные по природе, и одинаково они не чинятся.

- **Путь отказа конструктора** (`speechkit.go:55`): клиент распознавания создан,
  а клиент операций создать не удалось. Оба отказа независимы, и конвенция для
  такого случая называет `errors.Join`. Ошибка конструктора получает вторую
  строку, если закрытие тоже отказало.
- **Остановка процесса** (`main.go:124`): отдавать отказ некому — процесс
  заканчивается. Значит, он идёт в журнал владельца, а `defer` получает тело с
  проверкой. В запись идёт **значение ошибки как есть**: оно несёт состояние
  клиента и адрес узла, но не тело запроса и не ключ — ключ живёт в метаданных
  вызова, а не в соединении. Разворачивать первопричину на границе клиента, как
  требует `docs/conventions/logging.md` от ошибок транспорта, здесь нечего:
  обёрток на этом пути нет.

**Чем этот отказ является на самом деле — сказано прямо, чтобы обоснование не
поехало дальше в неверном виде.** `grpc.NewClient` ленив и соединения не
открывает, а `Close` у клиента возвращает отказ единственным образом — при
повторном закрытии. Значит, на пути отказа конструктора второй операнд почти
наверняка `nil`, а запись при остановке процесса говорит о **нашей** ошибке
(закрыли дважды), а не о недоступности Yandex. Обработка обоих мест остаётся:
она стоит одну строку и переживёт смену клиента, а её отсутствие каждый раз
приходится заново обосновывать читателю.

**Оракул этого решения чинится машиной, а не обещанием.** Правило `errcheck`
сегодня молчит на `_ = conn.Close()`: настройка `check-blank` не выставлена, а её
умолчание — «пропускать». То есть реализация, выбрасывающая отказ в пустоту,
удовлетворяет критерию приёмки «линтер не даёт замечаний `errcheck`», не
удовлетворяя самому критерию — «отказ возвращается либо попадает в журнал».
Поэтому изменение включает `errcheck.check-blank: true` в `.golangci.yml`.
Проверено прогоном: на сегодняшнем коде правило замечаний не добавляет, то есть
включается чисто и отдельного коммита приведения не требует.

Это правка политики линтера, и её цена названа: `_ =` перестаёт быть способом
сказать «отказ здесь не важен» молча — теперь такое место придётся либо
обрабатывать, либо вносить в `exclude-functions` поимённо, то есть заметно.

**Рассмотрено и отвергнуто — дописать оба типа в `exclude-functions`
`.golangci.yml`.** Соблазн сильный: список исключений там уже есть, и в нём
записана ровно эта политика — «закрытие через `defer` и лучшая-попытка уборки
файла — осознанно без проверки». То есть замечание снимается одной строкой
конфига, и она даже выглядит согласованной с прежним решением.

Отвергнуто: политика в конфиге относится к закрытию, у которого **отказ ничего
не значит** — файл, читатель, соединение с базой на выходе. Здесь не так: в
первом случае отказ закрытия сопровождает уже случившийся отказ и полезен для
разбора, во втором — это единственный признак того, что соединение с оплачиваемым
внешним сервисом закрылось неправильно. Записав их в исключения, мы бы
расширили политику молча, самим фактом добавления строки, и потеряли бы оба
сигнала навсегда. Критерий приёмки задачи требует именно «возвращается либо
попадает в журнал», а не «линтер замолчал».

### Решение 3: узнавание по смыслу шире приведения, и граница ставится нормой

Приведение типа видело **только вершину** цепочки. `errors.As` видит признак на
любой её глубине — в этом и цель, но у расширения есть встречная сторона: отказ,
к которому признак пустого прогона примешался по дороге (обёрткой или
`errors.Join`), воркер зачтёт пустым прогоном. Тогда задача останется в своём
состоянии и будет переопрашиваться раз в секунду без единой записи — тот самый
класс, от которого защищает инвариант «Принятая запись не теряется молча», только
с обратным знаком относительно чинимого дефекта.

Прямого места, где это случается, сегодня нет: признак рождается ровно в двух
местах и никем не оборачивается. Но `%w` объявлен умолчанием проекта, а
`errors.Join` вводит в кодовую базу это же изменение — значит, дыра появится
тихо и не сегодня.

Граница ставится **нормой, а не кодом**: спека требует, чтобы признак рождался
только ответом хранилища на опрос этим же шагом, и запрещает слою сохранять чужой
признак в цепочке своей ошибки.

**Рассмотрено и отвергнуто — научить воркер различать «признак на вершине» от
«признака в глубине».** Отвергнуто по цене: `errors.As` такого различения не
даёт вовсе, пришлось бы либо проверять вершину вручную (то есть вернуть
приведение типа, которое чинится), либо заводить свой обход цепочки. Код
усложняется ради случая, которого сегодня нет ни одного, а защита от него нужна
на входе — при написании нового слоя, — где норма работает, а проверка в рантайме
опоздала бы.

### Решение 4: спека заводится только на пустой прогон

Дельта заводит capability `pipeline` с одним требованием — о пустом прогоне
воркера. Имя предвосхищено маркерами долга в `docs/architecture.md`.

Закрытие соединения требования **не получает**: домена оно не трогает, наружу не
видно. Заводить под него норму значило бы нормировать внутреннюю гигиену —
граница спек проекта проходит не здесь. Судьёй остаётся критерий приёмки, а его
оракул сделан различающим включением `check-blank` (Решение 2), а не оставлен на
слово.

Требование при этом **не закрепляет нормой известный долг журнала.**
`docs/conventions/logging.md` держит расхождение: сбой фонового цикла
записывается дважды — шагом конвейера и следом воркером, — и уровнем `ERROR`
там, где конвенция просит `WARN` для повторяющегося сбоя. Спека говорит «ровно
один раз единственной логирующей точкой» и уровня не называет: иначе следующий,
кто возьмётся закрывать этот долг, обнаружил бы, что убрать вторую запись нельзя
без правки спеки, — и долг стал бы контрактом молча.
Остальное поведение конвейера (переходы состояний, захват, срок протухания)
спекой тоже не описывается: оно этим изменением не трогается, а требование,
написанное без проверки, — предположение, а не норма.

## Risks / Trade-offs

- **`errors.As` требует указателя на указатель, и ошибка формы не ловится
  компилятором, а даёт панику в рантайме** → цель объявляется переменной нужного
  типа (`var noop *contract.NoopJobError`), а ветка покрывается тестом, который
  и есть оракул критерия 2.
- **`defer` не сработает, если процесс уйдёт через `os.Exit`** → проверить, что
  между постановкой `defer` и штатным выходом `os.Exit` не вызывается; иначе
  запись о закрытии не появится, и это будет тихой потерей того же рода, что
  чинится.
- **Типы остаются, и довод конвенции о sentinel остаётся неотработанным** →
  назван открытым вопросом, а не забыт; работа отдельная.
- **`check-blank: true` меняет политику для всего проекта, а не для двух мест** →
  проверено прогоном: на сегодняшнем коде замечаний не добавляется. Цена в
  будущем — осознанное игнорирование отказа придётся объявлять в
  `exclude-functions`, а не писать `_ =` по месту.
- **Молчание на пустом прогоне остаётся единственным наблюдаемым состоянием
  цикла**: воркер, остановившийся по отмене или не стартовавший вовсе, выглядит
  так же, как воркер без работы → изменением не чинится и в требование не
  закладывается запрет на будущий признак живости: спека запрещает лишь запись
  **на уровне владельца**, оставляя место и `DEBUG`, и отдельному счётчику
  прогонов. Работа отдельная, идёт в урожай.
- **У пакета `internal/controller/worker` сегодня нет ни одного теста** → тест
  на пустой прогон заводится этим изменением и приносит с собой первую тестовую
  оснастку пакета: подменный журнал и чтение счётчика из реестра метрик. Оснастка
  рискует разойтись с той, что уже есть в `internal/service` и
  `internal/controller/http`; сверяется по ним, а не пишется с нуля.

## Migration Plan

Миграции нет: схема базы не трогается, данные не переносятся, формат файлов на
диске не меняется. Откат — обратный коммит.

## Open Questions

- **Переводить ли обе ошибки в sentinel** и убирать типы, которых никто не
  читает. Довод конвенции остаётся в силе; цена — правка пяти мест против двух.
  Решается отдельной работой, не этой.
- **Единая точка перевода доменной ошибки в HTTP-статус** — расхождение записано
  в `docs/conventions/errors.md` и этим изменением не закрывается.
