доменные ошибки сравниваются через errors.As, отказ Close не теряется

- признаки «работы нет» и «задача не найдена» узнаются по смыслу, а не
  приведением типа: обёртка `%w` на пути больше не превращает пустой прогон
  воркера в отказ раз в секунду
- отказ закрытия соединения с распознавателем доходит до вызывающего
  (`errors.Join`) либо до журнала; у `errcheck` включён `check-blank`, иначе
  критерий принимал реализацию, выбрасывающую отказ в пустоту
- заведены первые тесты пакета worker и capability `pipeline`; долг из четырёх
  замечаний линтера закрыт, гейт зелёный целиком
This commit is contained in:
av
2026-08-11 18:04:25 +03:00
parent b7dd060ba0
commit 2559d09fc8
24 changed files with 1554 additions and 48 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,218 @@
## 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` и этим изменением не закрывается.
@@ -0,0 +1,48 @@
## Why
Воркер отличает «работы сейчас нет» от настоящего отказа хрупким способом: он
смотрит на точный тип значения ошибки. Пока никто по дороге не добавил к ошибке
пояснения, это работает. Первое же пояснение, добавленное в любом месте пути,
сделает пустой прогон неотличимым от поломки — молча, без единого признака в
коде. Сервис начнёт раз в секунду на каждый из трёх воркеров писать в журнал
отказ, которого не было, и засчитывать несуществующие сбои в счётчик работы.
Инвариант проекта «пустой прогон — не ошибка» стоит ровно на этой проверке.
Сегодня же на этих местах красен линтер, и вместе с двумя потерянными отказами
закрытия соединения он держит гейт проекта красным целиком.
## What Changes
- Признак «работы нет» и признак «подходящей задачи не нашлось» перестают
зависеть от точной формы значения ошибки: они узнаются по смыслу и переживают
любые пояснения, добавленные по дороге.
- Отказ при закрытии соединения с распознавателем перестаёт теряться: он либо
доходит до вызывающего, либо попадает в журнал владельца.
- Поведение снаружи не меняется: пользователь, внешняя программа и набор
состояний задачи остаются прежними.
- Гейт проекта становится зелёным целиком — снимается объявленный долг из
четырёх замечаний линтера.
## Capabilities
### New Capabilities
- `pipeline`: конвейер расшифровки — как задача переходит между состояниями, что
делает воркер, когда работы нет, и что считается отказом шага. Имя предвосхищено
маркерами долга в `docs/architecture.md`; этим изменением заводится **только**
требование о пустом прогоне, остальное поведение конвейера дописывает задача,
которая его тронет.
### Modified Capabilities
Нет. Требования `intake` изменение не трогает.
## Impact
- `internal/contract` — форма признаков «задачи нет» и «задача не найдена»;
- `internal/controller/worker` — проверка пустого прогона, журнал и счётчик
работы; у пакета сегодня нет ни одного теста, изменение заводит первый;
- `internal/service` — перевод «задача не найдена» в «работы нет»;
- `internal/adapter/repo/sqlite` — рождение признака «задача не найдена»;
- `internal/adapter/recognizer/yandex` и `main.go` — отказ закрытия соединения;
- гейт проекта и запись долга в `docs/conventions/errors.md` и `CLAUDE.md`.
@@ -0,0 +1,477 @@
# Триаж ревью: errors-as-instead-of-typecast
## Сводка
- **Размер / сложность / метка:** среднее × знакомое → **medium**. Режим — по графу.
База диффа `origin/master` непригодна (удалённая ветка отстала на десятки
коммитов и даёт 9136 строк шума); реальный вход — рабочее дерево: 5 файлов кода
и конфига, 3 документа, 2 новых теста, каталог `openspec/changes/`.
- **Состояние гейта:** зелёный. Проверено проходом `autotests` дважды (exit 0) и
переспрошено триажем поимённо: `golangci-lint run``0 issues`,
`go test ./...` → все пакеты `ok`. Объявленных долгов у гейта после этого
изменения нет — заявление `CLAUDE.md` подтверждено выводом инструментов, а не
декларацией.
- **Находок на входе:** 12 сырых от четырёх проходов → **9 различных причин**
после дедупликации (`Close`/`err2` пришёл трижды, MUST «ровно один раз» —
дважды) → **5 в первых двух секциях**, 3 в гипотезах, 1 отсеяна.
- **Потолки не срабатывали:** 2 + 3 = 5 при допустимых 3 + 4. Ничего не срезано,
ничего не выброшено молча.
### Сигнал о заниженной метке
**Пришёл, от одного прохода — `review-code`.** Основания: четыре узла правки,
изменение политики линтера на весь проект (`errcheck.check-blank`), переписанный
раздел «Гейт» в `CLAUDE.md`, введение новой capability `pipeline`. С меткой
`large` запускались бы отдельные проходы `architecture` и `operations` вместо
разбора этих тем внутри `basics`.
`review-basics` запускался и сигнала о метке не подал. Согласие/несогласие
проходов приоритет меняет, `confidence` — нет: метку выбирал `review-scope`.
**Ретроспективно сигнал подтверждается исходом:** три из пяти оставшихся находок
— про документы и норму (`architecture.md`, `conventions/README.md`,
`docs/review.md`, delta-спека), то есть ровно про тот слой, который на метке
`medium` разбирается наименее глубоко.
### План разметки с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| requirements | дельта `specs/pipeline/spec.md` | разбор | `specs` | **закрыта**, 2 находки (1 дошла до блокирующей) |
| autotests | `CLAUDE.md` «Гейт» | — | `autotests` | **закрыта**, 1 находка (понижена в гипотезы) |
| conventions | `docs/conventions/` (errors.md, logging.md, README.md) | разбор | `code` | **закрыта**, 4 находки (1 блокирующая, 1 отсеяна) |
| architecture | `docs/architecture.md` + `passport.md` | разбор | `basics` | **закрыта**, 1 находка |
| security | `docs/security.md` | разбор | `basics` | **закрыта**, 0 находок (все три вопроса неприменимы) |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | разбор | `basics` | **закрыта**, 1 находка (понижена: изменением не создана) |
Своих тем проекта нет. **Тем без отчёта нет** — все шесть вернули вывод.
---
## Блокирует мердж
### 1. Дельта-спека закрепляет контрактом поведение, которого у системы нет, и после архивации станет ложной посылкой для следующей задачи
- Файл: `openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md:36-40`
- Severity: major (`critical` не ставится: построенного пути к отказу или потере
данных нет — вред отложенный и через следующего автора)
- Confidence: high
- Найдено проходами: `specs` (находка 1), `basics` («дешевле переделать», п. 1) —
две независимые формулировки одной причины; оракула у них не было, оракул добыт
триажем.
- **Оракул (мой прогон, не на слово):**
```
go test -overlay=<scratchpad>/overlay.json -run TestTriageOneFailureOneRecord -v ./internal/service/
```
Тест поднимает `findJob` с репозиторием, отдающим `errors.New("database is gone")`,
и логирует возвращённое так, как это делает `worker.go:59`. Вывод:
```
level=ERROR msg="Failed to find and acquire job" state=created error="database is gone"
level=ERROR msg="Worker error" worker=probe error="failed find and acquire job: created, database is gone"
--- FAIL: один отказ дал 2 записей уровня ERROR, требование спеки — ровно одна
```
Второй оракул — дословный критерий приёмки этого же изменения
(`tasks.md`, «Добавлено разбором дизайна»): «**Норма не закрепляет контрактом
то, что конвенции помечают строкой «Расхождение»: место записи и уровень
журнала остаются долгом `docs/conventions/logging.md`**». Спека нормирует
именно место записи. Долг записан в `docs/conventions/logging.md:159-162`.
- Последствие: требование `MUST быть записан ровно один раз единственной
логирующей точкой` не имеет ни сценария (проверить его нечем — покраснеть оно
не может), ни срока, ни владельца, ни маркера долга. Оговорка «сегодняшнее
расхождение этим требованием нормой не объявляется» снимает обратное прочтение,
но не снимает сам MUST. После `archive` абзац переезжает в
`openspec/specs/pipeline/spec.md` и читается следующим автором как
действующая норма: он либо «починит» вторую точку записи, не приняв
сознательно решение об уровне (`ERROR` против `WARN` — дизайн его намеренно
не принимал, `logging.md` требует `WARN` для повторяющегося сбоя фонового
цикла), либо норма сгниёт. По `CLAUDE.md` («что такое сделана»: гейт зелёный
**и критерии приёмки проверены поимённо») изменение сейчас не выполнено по
своему собственному критерию.
- Предложение: см. варианты в развилке.
- **Действие: развилка.**
> В дельта-спеке `pipeline` абзац «Отказ шага MUST быть записан ровно один раз
> единственной логирующей точкой» нормирует поведение, которого у системы нет
> (оракул: один отказ хранилища даёт две записи ERROR), и нарушает
> собственный критерий приёмки задачи. Как поступаем?
>
> **(а) Ослабить норму до проверяемого сегодня.** Оставить в требовании только
> «отказ шага MUST быть виден владельцу записью в журнале и засчитан в счётчик
> с пометкой отказа» — это покрыто сценарием «Шаг отказал» и проверкой
> `TestFailureIsLoggedAndCounted`. Единственность логирующей точки вынести
> отдельной строкой долга со ссылкой на `docs/conventions/logging.md:159-162` и
> завести задачу в `tasks/items/`. Цена: правка дельта-спеки → **одобрение
> дизайна отменяется, нужен возврат на чекпоинт**; кода не касается.
>
> **(б) Убрать вторую точку записи в этом же изменении.** Снять
> `s.logger.Error("Failed to find and acquire job", …)` из
> `internal/service/transcribe.go:398`, оставив запись воркеру. Цена: это чужой
> долг и рост scope; требует сознательного решения об уровне записи, которое
> дизайн не принимал; нужен новый сценарий и новая проверка на «ровно один
> раз». Дельта-спека при этом всё равно правится (добавляется сценарий).
>
> **(в) Оставить как есть.** Цена: в актуальную спеку уезжает MUST, который
> система нарушает с момента `apply` и который никогда не покраснеет.
**Прямой ответ на заданный вопрос: да, принятие этой находки меняет
дельта-спеку — в любом из вариантов (а) и (б).** По правилу скилла `resolve`
это отменяет одобрение дизайна и требует возврата на чекпоинт к человеку.
Вариант (в) чекпоинта не требует, но оставляет дефект.
### 2. Закрытый долг остался записан в трёх местах, и одно из них — раздел, которым триаж отсеивает ложноположительные: следующая настоящая находка того же класса будет молча выброшена
- Файлы: `docs/conventions/README.md:21`, `docs/conventions/README.md:54`,
`docs/review.md:69-74`
- Severity: major
- Confidence: high
- Найдено проходом: `code` (находка B); третье место (`docs/review.md`) добавлено
триажем — это тот же дефект в третьем доме.
- **Оракул — дословные строки, живые на момент триажа:**
- `docs/conventions/README.md:21`: «…лог пишется на каждом шаге и дублируется
воркером, **доменные ошибки проверяются приведением типа**.» — расхождения
больше нет, изменение его закрыло.
- Правило самого же README (строки 27-29): «Каждое такое место названо в своей
записи строкой «*Расхождение:*». … **Проходу ревью строка «Расхождение»
говорит, что находка на этом месте уже известна и новой не считается.**»
- `docs/review.md:69-74`, раздел «Типовые ложноположительные»: «Настоящий
дефект рядом другой — **проверка идёт приведением типа и сломается при первой
же обёртке; он уже записан в
[conventions/errors.md](conventions/errors.md)**» — ссылка ведёт в файл, из
которого этот текст изменением удалён (`git diff docs/conventions/errors.md`,
строки 51-57 сняты).
- `docs/conventions/README.md:54`: «Непроверенное возвращаемое значение ошибки |
`.golangci.yml` → `errcheck` (кроме `defer Close` и `send`)» — таблица не
знает о включённом `check-blank`, то есть о том, что `_ = x.Close()` теперь
краснеет.
- Задача сама называет ровно две правки (`tasks.md`, 4.2): «В
`docs/conventions/errors.md` снять …; **прочие расхождения того файла** не
трогать». Про соседний `README.md` и про `docs/review.md` там нет ничего —
места просто пропущены, а не оставлены сознательно.
- Последствие: класс «молчание». `docs/review.md`, «Типовые ложноположительные»
— единственный проектный вход в шаг отсева триажа. Пока строка жива, следующий
прогон ревью, увидев приведение типа в новом коде, обязан отнести находку к
известным и выбросить её — то есть регрессия ровно того дефекта, который
чинило это изменение, пройдёт молча. `docs.py check` этого не ловит: ссылки не
битые, битым стало утверждение внутри документа.
- Предложение: снять фразу «доменные ошибки проверяются приведением типа» из
`README.md:21`; снять или переписать первый пункт «Типовых ложноположительных»
в `docs/review.md` — оговорка про настоящий дефект рядом больше неверна, сам
же пункт про `NoopJobError` остаётся верным; в строке таблицы «Механизировано»
заменить «(кроме `defer Close` и `send`)» на актуальный перечень
`exclude-functions` и упомянуть `check-blank: true`.
- **Действие: инлайн.** Три текстовые правки, решение однозначно, инвариантов не
трогает.
---
## Стоит исправить сейчас
### 3. `Close()` адаптера SpeechKit теряет отказ закрытия второго соединения — ровно тот дефект, который изменение объявило закрытым своим критерием приёмки
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:79-91`
- Severity: minor (ущерб мал — см. находку 4: на этом пути оба `Close` в
сегодняшней реализации возвращают `nil`; вес держится критерием приёмки, а не
последствием)
- Confidence: high
- Найдено проходами: `specs` (находка 2), `code` (находка D), `basics` (находка
E) — **три независимых попадания, оракула ни у одного нет**. Совпадение
повышает приоритет (значит, бросается в глаза), но не `confidence`: под всеми
проходами одна модель.
- Оракул — дословный критерий приёмки этого же изменения (`tasks.md`, раздел
«Критерии приёмки»): «**Отказ `Close` не теряется молча: он либо возвращается
вызывающему, либо попадает в лог.**» Код:
```go
if err1 != nil {
return err1
}
return err2 // err2 при ненулевом err1 теряется молча
```
`errcheck` этого не видит: значение присвоено переменной. То есть механизация,
ради которой изменение включило `check-blank`, здесь мимо.
- Последствие: при остановке процесса, если оба gRPC-соединения отказали в
закрытии, владелец увидит один отказ из двух и будет разбирать половину
картины. Вероятность низкая, стоимость правки — одна строка.
- Предложение: `return errors.Join(err1, err2)` — тот же приём, который изменение
уже применило в конструкторе восемью строками выше, и `nil` из него отбрасывается.
- **Действие: инлайн.**
### 4. Комментарии и `design.md` описывают поведение gRPC, которого нет: журнал отправит владельца разбирать недоступность Yandex по ошибке, которая может возникнуть только от нашего двойного закрытия
- Файлы: `internal/adapter/recognizer/yandex/speechkit.go:56-59`, `main.go:124-129`,
`openspec/changes/errors-as-instead-of-typecast/design.md:81-92`
- Severity: minor
- Confidence: high
- Найдено проходом: `code` (находка A). Оракул проверен триажем поимённо.
- **Оракул — исходники `google.golang.org/grpc@v1.74.2`:**
- `clientconn.go:145` — `func NewClient(...)`: конструктор ленив, соединения не
открывает (устанавливает его первый RPC либо явный `Connect()`);
- `clientconn.go:1142-1156` — `Close()` возвращает **только** `nil` либо
`ErrClientConnClosing`, и только при повторном закрытии (`if cc.conns == nil`);
- `clientconn.go:67` — `ErrClientConnClosing = status.Error(codes.Canceled,
"grpc: the client connection is closing")`: статическая константа.
- Последствие, по местам:
- `speechkit.go:56-59` — комментарий обещает «**уже открытое** соединение могло
не закрыться»; на деле второй операнд `errors.Join` на этом пути **всегда
`nil`**. Конструкция безвредна и защищает от смены реализации, но читатель
выведет из комментария неверную модель API;
- `main.go:124-129` — комментарий «недоступность внешнего сервиса разбирает он»
и уровень `ERROR` (по `logging.md` — класс «сбой БД, диска, недоступность
внешнего сервиса»). Запись достижима только двойным закрытием, то есть нашим
дефектом. Владелец, увидев её, пойдёт разбирать Yandex вместо своего кода;
- `design.md:81-92` — тот же неверный образ записан двумя утверждениями
(«соединение с распознаванием уже открыто»; ошибка «несёт состояние
соединения и адрес узла» — `ErrClientConnClosing` не несёт ни того, ни
другого) и после архивации поедет дальше как обоснование.
- Предложение: код не трогать. Привести к действительности три текста: в
`speechkit.go` — «`grpc.NewClient` ленив, закрытие здесь почти всегда `nil`;
`errors.Join` стоит на случай смены реализации»; в `main.go` — «единственный
достижимый отказ здесь — повторное закрытие, то есть дефект наш, а не
внешнего сервиса»; в `design.md` — снять оба неверных утверждения.
- **Действие: инлайн.** Правка `design.md` фактическая, а не решенческая:
решение («отказ закрытия — по месту, а не единым правилом») остаётся тем же,
меняется только неверное описание чужого API. Дельта-спеку не трогает,
чекпоинта не требует.
### 5. Преамбула `docs/architecture.md` описывает состояние после архивации: сегодня она утверждает существование спеки, до которой нет пути
- Файл: `docs/architecture.md:11-23`
- Severity: minor
- Confidence: high
- Найдено проходом: `basics` («дешевле переделать», п. 2)
- Оракул — состояние дерева на момент триажа: `ls openspec/specs/` даёт **только**
`intake`; в новой преамбуле у пункта `intake` ссылка есть
(`../openspec/specs/intake/spec.md`), у пункта `pipeline` ссылки **нет** — она
снята, потому что `docs.py check` краснел битой ссылкой. Задача предписала эту
правку сама (`tasks.md`, 4.4).
- Последствие: пока изменение не заархивировано, документ утверждает «Заведены
две capability», а найти вторую читателю негде — единственная форма, в которой
она существует, лежит в `openspec/changes/`. Если изменение уедет без
`archive` (а `archive` — отдельный шаг и отдельная команда), расхождение
останется постоянным, и поймать его нечем: гейт зелёный именно потому, что
ссылку сняли.
- Предложение: у пункта `pipeline` дописать оговорку о том, где спека лежит
сейчас и когда переедет — «дельта в
`openspec/changes/errors-as-instead-of-typecast/specs/pipeline/spec.md`,
переезжает в `openspec/specs/` при архивации задачи». Одно предложение, ссылка
на существующий файл, гейт остаётся зелёным.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
### Новая ветка `errors.Join` в `newSpeechKitService` не покрыта ни одним тестом
Понижено с **major/high** до гипотезы и, по существу, до строки в границах
покрытия. Пришло от прохода `autotests` с настоящим оракулом
(`go tool cover -func` → `newSpeechKitService 0.0%`; триаж перепроверил:
`go test -coverprofile` даёт `internal/adapter/recognizer/yandex — coverage:
0.0% of statements`, тестовых файлов в пакете нет вовсе).
Почему понижено: находка 4 показывает, что ветка практически недостижима.
`grpc.NewClient` с константным корректным адресом (`operation.api.cloud.yandex.net:443`)
и валидными TLS-credentials отказывает только на разборе target'а, а второй
операнд `errors.Join` на этом пути всегда `nil`. То есть непокрыт код, который
и выполняться-то не будет. **Предложенное самим проходом «вынести создание
клиента за шов» триаж не рекомендует**: это разросшаяся абстракция в адаптере
ради единственной недостижимой ветки, и заказывать её на основании процента
покрытия — ровно та правка, от которой защищает потолок. Принят второй вариант
самого же прохода: занести в границы покрытия (сделано ниже).
### Признака живости воркера нет: сутки тишины одинаково означают «записей не слали» и «все три воркера висят»
Пришло от `basics` (находка F), понижено: **изменением не создано**. Спека
нормирует молчание пустого прогона, но само молчание стоит на инварианте
`CLAUDE.md` «`NoopJobError` — не ошибка», который старше этой задачи.
Оракула на «воркер висит» нет — поднять SpeechKit в тесте нечем (см. «Недоступно
проверке»). Более того, свойство **уже заведено задачей**:
`tasks/items/service-observability.md`, критерий завершения 1 — «По метрикам
видно, что конвейер встал: задача висит в состоянии дольше обычного, и это
отличимо от «работы нет»». Новой находкой не считается; чинить в этом изменении
нечего, иначе это рост scope на целую тему наблюдаемости.
### Норма спеки пересказана прозой в `docs/conventions/errors.md` — второй дом факта
Пришло от `basics` («дешевле переделать», п. 3). Оракула нет: `openspec/config.yaml:36`
действительно предупреждает, что «второй дом факта расходится с первым молча»,
но новый абзац `errors.md:51-57` заканчивается словами «**Норма записана
требованием capability `pipeline`**», то есть первый дом назван явно. Разделение
здесь защитимо: спека говорит, что делает система, конвенция — как это пишут в
коде. Доказательства предстоящего расхождения у меня нет, а превентивная правка
свелась бы к спору о вкусе. Оставлено гипотезой; если расхождение когда-нибудь
случится, эта запись — след.
---
## Promote candidates
- **Форма `msg` в журнале.** `logger.Error("failed to close audio recognizer", …)`
(`main.go:126`) — предложение, а не короткая категория, вопреки
`docs/conventions/logging.md:32-37`. Пришло от `code` (находка C) и **отсеяно
как находка**: `logging.md:44` уже несёт строку «*Расхождение:* сегодня `msg` —
предложение вида `Starting conversion job`», и весь корпус журнала написан в
этой форме; правка одной строки сделает журнал неоднороднее, а не однороднее.
Это претензия на правило, а не на этот код: либо сканер формы `msg`, либо
отдельная задача на разовую миграцию всего корпуса.
- **Устаревание утверждения внутри документа не механизировано.** `docs.py check`
ловит битые ссылки и раскладку, но не ловит ситуацию находки 2: файл на месте,
ссылка цела, неверным стало утверждение о его содержимом. Кандидат:
проверка «строка «*Расхождение:*» и упоминание расхождения в чужом файле
живут парой» либо явные якоря вместо ссылок на файл целиком.
- **Покрытие изменённых строк не считается ничем** (`CLAUDE.md`, «Гейт», сказано
прямо). Именно поэтому проход `autotests` вынужден был звать `go tool cover`
руками, а решение «покрывать или занести в границы» принималось на глаз.
Кандидат в шаг гейта, а не в находку ревью.
---
## Границы покрытия
### План: темы, их дома и глубины
Все шесть тем плана (`requirements`, `autotests`, `conventions`, `architecture`,
`security`, `operations`) имели дом и вернули отчёт — см. таблицу в сводке. Тем
без дома в плане нет. Своих тем проекта нет.
### Какие проходы запускались и в каком режиме
Метка `medium`, режим «по графу». Запущены: `review-autotests`, `review-specs`,
`review-code`, `review-basics`, `review-triage`. Состав соответствует плану
`review-scope`.
### Какие проходы не запускались и почему
- Отдельные проходы **`architecture` и `operations`** — по метке: на `medium`
эти темы разбираются внутри `review-basics` меньшей глубиной. `review-code`
подал сигнал, что метка, вероятно, занижена (см. сводку); при `large` эти два
прохода шли бы отдельно и глубже.
- Прохода **идиоматичности** в конвейере нет — упразднён.
- Прохода **независимой реализации** в конвейере нет — снят по стоимости.
### Что каждый запущенный проход не мог проверить в принципе
- `autotests` — судит оракулы и их способность краснеть, но не судит, верна ли
сама норма, которую они проверяют; поведение под реальным потоком не
воспроизводит.
- `specs` — судит соответствие нормы и кода, но не судит, нужна ли норма и не
дорога ли она; альтернативной формулировки требования не строит.
- `code` — читает дифф; поведения системы целиком, в сборе и под нагрузкой, не
наблюдает.
- `basics` — тремя темами на малой глубине; ни одну из них до дна не доводит по
построению.
- `triage` (я) — **ничего нового не нахожу по определению**: я не читаю код в
поисках дефектов, я работаю с чужими выводами. Пропуск любого прохода — мой
пропуск тоже, и единственное, что я могу с этим сделать, — назвать его
поимённо, что и сделано выше.
### Что осталось целиком на человеке
**Не проверит ни один проход** (`docs/review.md:159-166`):
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно** (`docs/review.md:168-175`):
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс этим прогоном:**
- `internal/adapter/recognizer/yandex` — покрытие **0.0 %**, тестовых файлов в
пакете нет вовсе; новая ветка `errors.Join` в `newSpeechKitService:53-63` не
исполняется ни одной проверкой. Заносится сюда сознательно вместо заведения
шва (см. гипотезы);
- `main.go` — пакет `main` в проекте никогда не тестировался, шва нет; новый
`defer` с логированием отказа закрытия проверен только чтением (`tasks.md`,
2.4: между постановкой `defer` и завершением `main` нет `os.Exit`);
- реальный путь `NoopJobError` от SQLite-репозитория (не от подставного) —
проверки обоих звеньев работают на заглушках.
**Общее, что не проверяет никто:** история инцидентов; поведение под реальным
потоком; поведение внешних систем в их версиях (утверждения о gRPC в находке 4
сняты с исходников `v1.74.2` — на другой версии их надо перепроверять);
завязка потребителей на текущее поведение; вопрос «а нужна ли эта
функциональность вообще».
### Каких документов проекта не хватило
- **`docs/adr/` по теме этого изменения — записи нет.** Решение «`NoopJobError`
и `JobNotFoundError` остаются типизированными, а не становятся sentinel»
принято в `design.md` (Решение 1) и живёт только там, в документе изменения,
который после архивации уедет в `openspec/changes/archive/`. Через полгода
обоснование придётся выводить заново.
- **Строка «*Расхождение:*» не имеет владельца и срока по построению**
(`docs/conventions/README.md:27-29`). Из-за этого долг «одна запись отказа —
две строки журнала» нельзя ни просрочить, ни закрыть — что и породило находку 1.
- Прочих пробелов проходы не заявили. `docs/security.md`, `docs/database.md`,
`docs/passport.md`, `docs/conventions/*` на месте и периметр называют;
инварианты в `CLAUDE.md` есть и снабжены severity — деградации по этому
разряду на этом прогоне не было.
### Сработавшие потолки — по строке на проход
- `review-basics` — потолок **2/4**, показано 2 находки + 3 пункта «дешевле
переделать до мерджа». Потолок не срабатывал, за срезом ничего не осталось.
Проход сообщил это сам.
- `review-autotests` — **о своём потолке не сообщил**; показана 1 находка и 1
отклонённая гипотеза. Это находка о прогоне: сколько осталось за срезом,
установить нечем.
- `review-specs` — **о своём потолке не сообщил**; показано 2 находки. То же.
- `review-code` — **о своём потолке не сообщил**; показано 4 находки при
раздельных потолках половин `conventions` и `техника`. То же.
- `review-triage` (я) — потолки 3 и 4, занято 2 и 3. **Потолок не срабатывал,
ничего не срезано, ничего не выброшено молча.** Единственная отсеянная находка
(форма `msg`) названа поимённо в `Promote candidates` с причиной отсева.
### Четыре строки триажа: чего в конвейере нет вовсе
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Изменение вводит новую capability `pipeline` и меняет
политику линтера на весь проект; расходится ли это с записанными ADR, ни один
проход не проверял. Ловит такое сверка документации — скилл
`av-dev-docs:healthcheck`, и звать его надо руками (`CLAUDE.md`, «Гейт»:
«согласованность документов между собой и с кодом … звать его надо руками»).
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Каждое число в этом отчёте снято на этом прогоне приложенной
командой: `0 issues` от `golangci-lint run`, `0.0 % of statements` от
`go test -coverprofile`, «2 записи ERROR» от прогона с `-overlay`. Чисел без
команды замера в отчёте нет.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** `errors.Join` в конструкторе, `errors.As` с указателем на
указатель, `defer` с телом вместо голого вызова — все три конструкции судились
по внутренним конвенциям проекта и по линтеру. Различение «идиоматично против
просто распространено» не спрашивал никто с тех пор, как упразднён проход про
идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру. Вопрос
«а не решается ли задача «признак не ломается обёрткой» иначе — например,
sentinel-значениями, как сам `design.md` рассматривает в Решении 1» никем
независимо не проверялся: рассмотрел и отверг его автор дизайна, и ревью
сверялось с его же рассуждением.
Метка `medium`, поэтому пятая строка про `small` не применяется: темы `security`,
`operations` и `architecture` разбирались проходом `basics` по своим домам, а не
только по инвариантам `CLAUDE.md`.
---
**Формулировка «критичных проблем не обнаружено» в этом отчёте не употребляется
и не подразумевается.** `critical` не выставлен ни одной находке по конкретной
причине: построенного пути к потере данных, порче или утечке секрета ни один
проход не предъявил, а `critical` без оракула или построенного пути не
существует. Что именно осталось непроверенным — перечислено выше поимённо.
@@ -0,0 +1,76 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
Описан пока **только пустой прогон воркера** — тот, что нормируют проверки
пакета `internal/controller/worker` и перевод признака в `internal/service`.
Сознательно не описаны переходы состояний и цепочка `created → converted →
transcribe → done | failed`, захват задачи и срок его протухания, отмена
контекста посреди шага, освобождение ресурсов внешних клиентов. Это не значит,
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Пустой прогон воркера — не отказ
Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На
пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы
и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST
узнаваться по смыслу значения, а не по его точной форме, и MUST переживать
пояснения, добавленные к этому значению на любом промежуточном шаге пути.
Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: три воркера
опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт три
записи отказа в секунду и столько же засчитанных сбоев, которых не было.
Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же
шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак
в цепочке своей ошибки. Узнавание по смыслу видит признак на любой глубине, и
отказ, к которому признак примешался, воркер зачёл бы пустым прогоном: задача
осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи
— ровно то, что запрещает инвариант «Принятая запись не теряется молча».
Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST
быть засчитан в счётчик работы с пометкой отказа.
**Сколько раз он записывается и каким уровнем — это требование не нормирует, и
умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг
конвейера и следом воркер, — а уровень стоит `ERROR` там, где конвенция просит
`WARN` для повторяющегося сбоя фонового цикла. И то и другое записано долгом в
`docs/conventions/logging.md`, раздел «Ошибки», строкой «Расхождение, и оно
системное». Долгом оно и остаётся: требование, объявившее одиночную запись
нормой, сделало бы недостижимое обязательным, а требование, объявившее нормой
двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг,
дописывает норму сюда.
#### Scenario: Работы в состоянии нет
- **GIVEN** ни одной задачи в опрашиваемом состоянии нет
- **WHEN** воркер делает свой прогон
- **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего
- **AND** счётчик работы воркера не растёт
#### Scenario: Признак пустого прогона дошёл с пояснением
- **GIVEN** работы в опрашиваемом состоянии нет
- **AND** промежуточный шаг добавил к этому признаку своё пояснение
- **WHEN** воркер делает свой прогон
- **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на
уровне владельца нет
#### Scenario: Шаг отказал
- **GIVEN** шаг конвейера вернул отказ
- **WHEN** воркер завершает прогон
- **THEN** отказ виден владельцу сервиса записью в журнале
- **AND** счётчик работы воркера растёт с пометкой отказа
#### Scenario: Шаг сделал работу
- **GIVEN** шаг конвейера отработал задачу без отказа
- **WHEN** воркер завершает прогон
- **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет
@@ -0,0 +1,100 @@
## 1. Признак узнаётся по смыслу
- [x] 1.1 В `internal/controller/worker/worker.go:51` заменить приведение
`err.(*contract.NoopJobError)` на `errors.As` с целью типа
`*contract.NoopJobError`; порядок ветвей (счётчик, затем журнал) сохранить.
- [x] 1.2 В `internal/service/transcribe.go:392` заменить приведение
`err.(*contract.JobNotFoundError)` на `errors.As`.
- [x] 1.3 `golangci-lint run` не даёт замечаний `errorlint` — проверить прогоном.
## 2. Отказ закрытия не теряется
- [x] 2.1 В `.golangci.yml` включить `errcheck.check-blank: true`. Без этого
`_ = conn.Close()` снимает замечание, и оракул раздела 4 не различает годную
реализацию от негодной. Прогон обязан остаться на прежних 4 замечаниях — новых
мест правило не открывает (проверено при разборе дизайна).
- [x] 2.2 В `internal/adapter/recognizer/yandex/speechkit.go:55` собрать отказ
закрытия `sttConn` с отказом соединения через `errors.Join`; `nil` от закрытия
форму ошибки не меняет.
- [x] 2.3 В `main.go:124` заменить `defer recognizer.Close()` на `defer` с телом,
пишущим отказ закрытия в журнал.
- [x] 2.4 Проверить, что между постановкой этого `defer` и штатным завершением
`main` нет вызова `os.Exit`: иначе запись не появится (риск из `design.md`).
- [x] 2.5 `golangci-lint run` не даёт замечаний `errcheck` — проверить прогоном.
- [x] 2.6 Мутация оракула: временно заменить оба места на `_ = …Close()`
`golangci-lint run` обязан покраснеть обоими. Не покраснел — оракул критерия 4
не работает, и шаг 2.1 сделан неверно. Восстановить код после проверки.
## 3. Оракул на пустой прогон — оба звена пути
- [x] 3.1 Завести первый тест пакета `internal/controller/worker`; оснастку
(подменный журнал, чтение счётчика из реестра метрик) взять по образцу тестов
`internal/service` и `internal/controller/http`, а не писать заново.
- [x] 3.2 Тест: воркер, чья работа вернула `*contract.NoopJobError`, **обёрнутый**
`fmt.Errorf("…: %w", err)`, не пишет в журнал ни одной записи.
- [x] 3.3 Тот же тест: счётчик `transcriber_worker_job_count` для этого воркера
не изменился — значение читается до и после прогона.
- [x] 3.4 Тест отказа: обычный отказ шага даёт запись в журнал и рост счётчика с
пометкой отказа — иначе оракул зелен на коде, который не считает отказом ничего.
- [x] 3.5 Тест успеха: прогон без отказа растит счётчик с пометкой успеха и не
пишет об отказе. Без него реализация, снявшая счёт успешных прогонов, проходит
все проверки, а доля отказов перестаёт считаться.
- [x] 3.6 **Второе звено пути**: тест в `internal/service` на `findJob` с
подставным репозиторием, возвращающим `fmt.Errorf("…: %w",
&contract.JobNotFoundError{…})` — ожидание `*contract.NoopJobError`. Без него
правка 1.2 принимается только линтером, а линтер проверяет форму, не смысл.
- [x] 3.7 Мутация, поимённо по обеим строкам: вернуть приведение типа в
`worker.go` — краснеют 3.2 и 3.3; вернуть приведение (или подставить
несовпадающую цель `errors.As`) в `transcribe.go` — краснеет 3.6. Обе мутации
обязаны покраснеть по отдельности. Восстановить код после проверки.
## 4. Учёт
- [x] 4.1 `task gate` зелёный целиком; в `CLAUDE.md`, разделе «Гейт», снять
запись об известном отказе `golangci-lint` и о задаче
`errors-as-instead-of-typecast`.
- [x] 4.2 В `docs/conventions/errors.md` снять пометку «*Расхождение, и оно
опасно:*» о приведении типа и строку преамбулы «доменные ошибки проверяются
приведением типа»; прочие расхождения того файла не трогать.
- [x] 4.3 Дописать `## Purpose` в спеку `pipeline` — что это за capability и что
сознательно **не** описано (переходы состояний, захват, срок протухания,
отмена контекста, освобождение ресурсов). Без него следующий автор не отличит
«остальное не нормировано» от «остального не бывает»; валидатор этого не ловит.
- [x] 4.4 Поправить преамбулу `docs/architecture.md`: заведены две capability,
`intake` и `pipeline`, и в `pipeline` описан только пустой прогон воркера.
Маркеры `<!-- канон: поведение → openspec/specs/pipeline -->` на строках про
идемпотентность и цепочку состояний оставить долгом — но так, чтобы их нельзя
было прочесть как «уже переехало».
- [x] 4.5 `openspec validate --strict errors-as-instead-of-typecast` проходит.
## Критерии приёмки
Дословно из записи задачи `tasks/items/errors-as-instead-of-typecast.md`:
- Обе проверки идут через `errors.As` либо через `errors.Is` по sentinel.
Оракул — `golangci-lint run` не даёт замечаний `errorlint`.
- Обёртка `fmt.Errorf("…: %w", err)` в середине пути не ломает распознавание.
Оракул — тест: обёрнутый `NoopJobError` воркер по-прежнему считает пустым
прогоном и не пишет ни лога, ни метрики.
- Метрика `transcriber_worker_job_count` на пустом прогоне не растёт. Оракул —
тот же тест, проверка значения счётчика до и после.
- Отказ `Close` не теряется молча: он либо возвращается вызывающему, либо
попадает в лог. Оракул — `golangci-lint run` не даёт замечаний `errcheck`, и
`task gate` зелёный целиком.
Добавлено разбором дизайна (рубрика прохода `rubric`, потолок пунктов не
применялся):
- Каждый оракул способен покраснеть на негодной реализации. Проверяется
мутацией — шаги 2.6 и 3.7; критерий, чей единственный оракул молчание линтера,
годится только когда линтер краснеет на **всех** негодных реализациях.
- Признак пустого прогона распознаётся на **обоих** звеньях пути, и каждое звено
имеет свою падающую проверку.
- Классификация отказа не зависит от текста ошибки: ни подстроки `Error()`, ни
точной формы значения.
- Норма не закрепляет контрактом то, что конвенции помечают строкой
«Расхождение», и не объявляет обязательным недостижимое: число записей об
отказе и уровень журнала остаются долгом `docs/conventions/logging.md`, а
требование их не нормирует ни в ту, ни в другую сторону. Проверено ревью кода:
первая редакция требовала «ровно один раз», чему код не соответствовал с
первого дня.