внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
@@ -0,0 +1,443 @@
# Триаж ревью: record-centric-model
## Сводка
- **Режим прогона:** по графу. **Метка:** `large`, обоснование разметки — «крупное ×
незнакомое» (смена модели очереди: захват, повторы и воркеры разом; конвейер задач
трогается целиком). Размер и сложность числом в переданном плане не названы; свой
замер объёма — 61 путь в рабочем дереве (`git status --short | wc -l`), изменение
лежит некоммитнутым поверх `d079f03`.
- **Состояние гейта:** зелёный, `task gate` exit 0 (проход `autotests`, лог в
`scratchpad/gate_run.log`).
- **Находок на входе:** 28 пронумерованных находок шести проходов плюс 14 пунктов в их
дополнительных секциях (specs — 9 «поведение вне спеки», architecture — 3 «дешевле
переделать до мерджа», ops — 2 ответа сверх перечня). После дедупликации по причине
осталось 24 различимые причины; в первых двух секциях — **7**.
- **Сверка с «Типовыми ложноположительными»** (`docs/review.md`) выполнена: под пункт
«файлы и объекты не удаляются, диск растёт» формально попадали две находки, обе
оставлены — у обеих есть замер, которого пункт и требует. Пункт про молчание воркера
на `NoopJobError` не выбрасывает находку №2, но **ограничивает её починку** — см.
внутри находки. Пункты про гонку захвата и про запись без владельца отменены
редакциями 2026-08-14 и к находкам этого прогона не применялись.
### План с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты change | разбор | specs | закрыта, 6 находок + секция из 9 пунктов |
| autotests | CLAUDE.md, «Гейт» | — | autotests | закрыта, 3 находки; гейт зелёный, флаки не найдены (3 прогона `-race`) |
| conventions | `docs/conventions/` | разбор | code | закрыта, 8 находок (4 техника + 4 конвенции) |
| architecture | `docs/architecture.md` + источник `passport.md` | доказательство | architecture | закрыта, 3 находки + секция из 3 пунктов |
| security | `docs/security.md` | доказательство | adversary | закрыта, 2 построенных пути + 1 свойство без пути |
| operations | `docs/architecture.md` «Эксплуатация» + источник `database.md` | доказательство | ops | закрыта, 5 находок |
| темы проекта | дома нет | — | basics | **не запускался**: своих тем у проекта нет, все документы `docs/` разошлись по шести темам ядра |
- **Тем без отчёта нет.** Каждая заявленная тема отчиталась; единственная строка «дома
нет» — `темы проекта`, и она заявлена такой в самом плане, а не потеряна на прогоне.
- **Сигнал о заниженной метке не пришёл ни от одного прохода.** `review-code`
отработал и возражений по метке не подал; `review-basics` на этом прогоне не
запускался, то есть его половина корректора не работала вовсе. Метку выбирал
`review-scope`, и независимая проверка метки прошла в одном лице из двух.
---
## Блокирует мердж
### Архив хранит не то, что пришло от провайдера: неизвестные поля ответа исчезают молча
- Файл: `internal/adapter/recognizer/yandex/speechkit.go:200-235`
- Severity: major
- Confidence: high
- Оракул: зонд прохода `specs``proto.Marshal` сохраняет неизвестное поле (4 байта),
пара `encodeResponses`/`decodeResponses` через `protojson` отдаёт 0 байт. Сверено
чтением на месте: `encodeResponses` зовёт `protojson.Marshal(resp)`, а комментарий
над ней обещает «ответ провайдера целиком, в том виде, в каком он пришёл».
Норма — дельта `recognition`, `openspec/changes/record-centric-model/specs/recognition/spec.md:36-38`:
«Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он пришёл».
- Последствие: `protojson` выбрасывает поля, которых нет в вендоренной схеме. Всё, что
SpeechKit добавит в ответ (и всё, что уже есть в версии сервиса новее нашей
go-genproto), в сохранённой попытке отсутствует, и узнать об этом нечем: разбор
проходит успешно. Ради этого архива и заведена коллекция `recognitions` — «станут
доступны, когда мы научимся их читать». Не станут. Повторное распознавание стоит
денег (`CLAUDE.md`, «Запреты», Yandex Cloud за деньги), а исходное аудио к тому
моменту может быть уже единственным, что осталось.
- Предложение: развилка, потому что решается формат файла на диске, а он в проекте
необратим (`CLAUDE.md`, «Работа»: «формат файла на диске» спрашивается у человека
всегда). Варианты: **(а)** хранить `proto.Marshal` — неизвестные поля переживают
цикл, цена: содержимое перестаёт читаться глазами и в панели; **(б)** оставить
`protojson` и переписать требование дельты, назвав цену прямо («храним разобранное
нашей схемой, а не пришедшее»); **(в)** хранить оба представления — цена в объёме
файла, вдвое.
- Найдено проходом: specs
- Действие: развилка
### Шаг сообщает исход одним `nil`, и остановка приговором засчитывается успехом наравне с откладыванием опроса
- Файл: `internal/service/transcribe.go:274-285`, `:634-644`, `:734-762`
- Severity: major
- Confidence: high
- Оракул: три независимых замера на живом хранилище (specs, code, ops) плюс сверка
чтением на месте. `failStep` (`:735-739`) возвращает `nil` после `halt`, ветка
«операция ещё идёт» (`:634-644`) тоже возвращает `nil`, а `RunStep` судит исход
только по `stepErr != nil` (`:274-285`). Замеры: остановленная запись даёт два
события — `halted`, следом `done`; счётчик `error=false` 1→2, `error=true` 0→0;
halt по `stuck``error=true` 0→0; 3 откладывания дают 3 строки `poll/done`,
50 циклов опроса — +50 строк `record_events`. Константа `EventOutcomeFailed`
объявлена (`internal/entity/record_event.go:15`) и не пишется ни одной строкой кода.
Нормы: `docs/architecture.md:156-161` — «Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` … Отдельного оповещения нет»;
дельта `pipeline`, `spec.md:264` — «Журнал MUST не писаться на каждое откладывание
опроса» и сценарий `spec.md:280-284` «Откладывание строки не пишет».
- Последствие: три причины остановки — исчерпанные попытки, застревание, приговор шага
— не двигают единственный канал владельца. Запись умерла, метрика показывает успех,
журнал записи утверждает `done`. Отправителю сообщение уходит (`halt` зовёт
`notify`), то есть инвариант «Принятая запись не теряется молча» формально держится
ровно наполовину: пользователь знает, владелец — нет. Второй половиной та же причина
забивает журнал: часовое распознавание кладёт ≈720 строк `done`, суточное — до ~17000
на одну запись, и настоящие события в нём тонут.
- Предложение: исход шага должен называться, а не выводиться из `nil` — отдельным
значением («сделано» / «отложено» / «остановлено»), и `RunStep` пишет `done` только
на первом. Остановка пишет `EventOutcomeFailed` (либо оставляет один `halted`) и
двигает `transcriber_worker_job_count{error="true"}` — тот, который назван каналом
владельца. **Ограничение починки, нарушить его нельзя:** считать в метрику сам
`NoopJobError`, которым `acquire()` возвращает остановленную запись воркеру,
запрещено инвариантом `CLAUDE.md``NoopJobError` — не ошибка», major) и записано
ложноположительным в `docs/review.md`. Значит счёт и событие ставит сам `halt`, а не
воркер.
- Найдено проходом: specs (2 находки), code (2), ops (2) — шесть формулировок одной
причины; оракулы независимые, `Confidence` от совпадения не растёт
- Действие: инлайн
### Единственный объявленный способ убрать запись оставляет полный текст речи на диске, а удаление учётной записи отвергается чужим сообщением
- Файл: `internal/adapter/repo/pocketbase/owner_guard.go:36-80`,
`internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go:225-302`
- Severity: major
- Confidence: high
- Оракул: прогон прохода `adversary` на живом хранилище — удаление строки
`audio_records` отвергается (связи приложений `Required:true` без каскада), удаление
строки `files` проходит молча, на диске остаётся
`storage/<recognitions>/<запись>/<имя>.payload` с текстом речи. Второй прогон:
удаление учётной записи с архивом даёт наш отказ с причиной, с одной темой —
«Make sure that the record is not part of a required relation reference». Сверено
чтением: `countOwned` перебирает `RecordsCollection` и `FilesCollection`, а
коллекций с колонкой `owner` в шаге схемы **три**`topics` заводится с полем
`owner` и уникальным индексом `idx_topics_owner_name`. Комментарий над функцией
обещает «по обеим коллекциям». Нормы: `docs/security.md:335-341` — «единственный
способ убрать запись — руками в базе и в каталоге на сервере», а задача
`delete-record` обязана убирать «все уровни текста»; дельта `storage` требует, чтобы
отказ называл причину.
- Последствие: владелец, выполнивший единственную записанную процедуру удаления,
получает отказ на строке записи и удаляет файл — после чего считает данные
удалёнными, а расшифровка речи человека остаётся на диске бессрочно. Второй путь:
собственный страж, заведённый ровно ради того, чтобы владелец не пошёл удалять
связи руками, на учётной записи с темой молчит и пропускает вперёд «ведущую»
подсказку библиотеки — то есть ведёт владельца делать необратимое. `docs/security.md`
этим изменением не тронут вовсе, хотя содержимое переехало в шесть коллекций и
завелась вторая раскладка файла на диске.
- Предложение: перечень коллекций с владельцем — одно место, выводимое из шага схемы
(инлайн-часть, `topics` добавляется сразу). Дальше развилка по удалению: **(а)**
завести каскад/процедуру, убирающую запись со всеми уровнями текста и файлами, до
мерджа; **(б)** оставить как есть, но переписать `docs/security.md` под новую
раскладку и назвать процедуру поимённо, включая `recognitions/*.payload`, и
дополнить «Затрагивает» задачи `delete-record`; **(в)** признать удаление
недоступным до `delete-record` и сказать это в `security.md` прямо.
- Найдено проходом: adversary (2 пути), code (1 находка о `topics`) — один корень
- Действие: развилка
---
## Стоит исправить сейчас
### Откат образа поверх применённого шага схемы не диагностируется: старый бинарь встаёт молча и ломает 100% очереди
- Файл: `internal/adapter/repo/pocketbase/migrations/migrations.go`,
шаг `202608140002_record_centric_model.go`
- Severity: major
- Confidence: high
- Оракул: замер прохода `ops` двумя реальными бинарями — старый бинарь поднимается на
каталоге новой схемы **без ошибки**, затем 100% обращений к очереди дают
`failed to find collection transcribe_jobs`. `RunAllMigrations` накатывает
недостающие из своего списка и шагов новее не видит; `down` коллекцию не
восстанавливает.
- Последствие: это первый шаг схемы проекта, который убирает коллекцию, а не
добавляет. Штатное средство владельца на инциденте — откатить образ — с этого
момента делает хуже и не говорит об этом: сервис стартует зелёным и отказывает на
каждой записи. Обратно чинится повторной выкладкой нового образа, то есть ущерб
обратим, но обнаруживается он в худший момент и не тем сообщением. Шаг схемы после
выкладки не переписывается (`CLAUDE.md`, инвариант, **critical**), поэтому дешёвая
минута — сейчас.
- Предложение: развилка. **(а)** старт отказывается работать на схеме новее своего
списка — явным сообщением «база новее бинаря, откат образа не поддержан»; цена:
проверка версии схемы на подъёме, ~десяток строк плюс её норма;
**(б)** записать в `docs/architecture.md`, «Эксплуатация», что откат образа через
этот шаг невозможен и что делать вместо него; цена: только текст, ловушка остаётся;
**(в)** признать осознанным и не делать ничего.
- Найдено проходом: ops
- Действие: развилка
### После жёсткого падения воркера запись невидима до восьми часов, а `own_work_limit_minutes` этим не управляет
- Файл: `internal/service/transcribe.go:296-339`,
`internal/adapter/repo/pocketbase/record_repo.go:190-215`, `internal/entity/stage.go`
- Severity: major
- Confidence: high
- Оракул: замер прохода `ops` — после захвата без `Save` повторный захват записи не
выдаёт; halt по `stuck` наступает только по истечении `acquire_expires_at`. Сверено
чтением: сторож простоя `isStuck` проверяется **после** захвата (`:328`), а захват
фильтрует по `acquire_expires_at` (`record_repo.go:215`), чей срок для приведения и
отправки — 8 часов (`docs/database.md:274-275`).
- Последствие: настройка продана владельцу как сторож зависания — «предел простоя, своя
работа, 60 минут, сторож ловит зависание» (`docs/database.md:279`), — но для
крашнутого или убитого держателя реальный предел вчетверо с лишним больше и задаётся
другим числом из другого файла. Запись человека молча стоит до восьми часов, и ни
один документ этого расхождения не называет. Класс — «молчание»: владелец узнает
только по отсутствию ответа.
- Предложение: свести к одному числу либо назвать оба и их роли — в
`docs/database.md` и в дельте `pipeline`. Развилка: **(а)** срок захвата опустить до
предела простоя (цена: многочасовое приведение начнёт терять захват и перезапускаться
— именно та цена, ради которой 8 часов и стоят); **(б)** оставить два числа, но
сделать протухший захват видимым (сторож простоя судит и по времени захвата);
**(в)** оставить как есть и записать в `database.md` прямо, что для крашнутого
держателя предел — срок захвата, а не `own_work_limit_minutes`.
- Найдено проходом: ops. **Понижено при триаже:** половина находки прохода
`architecture` — «имя ключа в коде разошлось с `design.md`» — из основного списка
снята: `config.example.toml`, `internal/config/config.go:35` и канон
`docs/database.md:279` называют ключ `own_work_limit_minutes` согласованно, разошёлся
один `design.md` изменения. Необратимости здесь нет, это дрейф документа — его дом
`av-dev:doc-healthcheck`, не ревью.
- Действие: развилка
### Переписанный конвейер уехал без проверок: пять тестов снесено, четыре узла с нулевым покрытием
- Файл: `internal/controller/worker/worker.go:100-147`,
`internal/adapter/recognizer/yandex/speechkit.go:206-280`,
`internal/controller/http/transcribe.go:140-166`,
`internal/adapter/repo/pocketbase/panel.go:33-99`,
`internal/adapter/repo/pocketbase/owner_guard.go:36-80`
- Severity: major
- Confidence: high
- Оракул: `go test ./... -coverpkg=./...` (проход `autotests`) — `Pool`/`NewPool`/
`Size`/`Start` 0.0%, `encodeResponses`/`decodeResponses`/`outcomeFromResponses` 0.0%,
`GetTranscribeJobStatus` 52.9% (ветка выдачи готовой расшифровки, ветка 500 при
отказе `textRepo` и новое поле `halted` без единого assert). `git status`: удалены
`owner_test.go`, `transcript_job_repo_test.go`, `file_repo_test.go` — пять проверок
снесено, а не переписано. Норма: `docs/review.md`, «Типовые узлы», «Любой узел» —
«изменённое место покрыто хоть одним **проходящим** тестом»; журнал 2026-08-10
показывает, чем это кончается.
- Последствие: пул одинаковых воркеров — предмет всего изменения — не поднимается ни
одним тестом; разбор реального ответа SpeechKit не проверен ничем, хотя `Parse`
чистая функция и сети не требует; панельный возврат записи в работу и страж удаления
учётной записи держатся на чтении глазами, и находка №3 показывает, что чтение уже
один раз промахнулось. Зелёный гейт здесь не означает проверенного кода.
- Предложение: инлайн, четыре адреса. Приоритет — `encodeResponses`/`decodeResponses`
(дешевле всех, чистые функции, и это оракул находки №1), `owner_guard` с тремя
коллекциями, ветки `GetTranscribeJobStatus` включая `halted`, подъём пула.
Панельный возврат — тестом против настоящего хранилища, как это делают
`schema_test.go` и `ownership_test.go`.
- Найдено проходом: autotests (3), specs (1)
- Действие: инлайн
### Колонка `source_uri` уезжает необратимым шагом схемы, и читателя у неё нет
- Файл: `internal/contract/repository.go`, `internal/contract/contract.go`,
шаг `202608140002_record_centric_model.go`
- Severity: minor
- Confidence: high
- Оракул: проход `architecture``grep` по рабочим путям: колонка пишется и не
читается ни одним, адрес объекта пересчитывается `SourceURI(objectKey)` на месте
употребления. Контракт распознавателя вырос с 3 методов до 9: `Upload`,
`ObjectExists`, `SourceURI` вынесли в ядро ключ объекта и идемпотентность заливки.
- Последствие: шаг схемы после выкладки не переписывается (`CLAUDE.md`, инвариант,
**critical**), поэтому снять колонку потом можно только новым шагом. Пока она есть,
следующий читатель обязан гадать, что из двух — колонка или пересчёт — правда, а
ядро обязано знать про объектное хранилище провайдера, чего оно знать не должно.
Цена сегодня — минуты, после мерджа — новый шаг схемы и вопрос человеку.
- Предложение: развилка. **(а)** свернуть заливку в один метод `EnsureUploaded` и
убрать `SourceURI`/`ObjectExists` из контракта; **(б)** оставить контракт и начать
читать `attempt.SourceURI` вместо пересчёта — тогда у колонки появляется читатель;
**(в)** признать колонку заделом осознанно и снять её из шага схемы **до** мерджа,
вернув отдельной задачей.
- Найдено проходом: architecture
- Действие: развилка
---
## Гипотезы без доказательства
Понижены: оракула нет либо путь не построен. Все — `major` и ниже по контракту.
- **Отказ чтения в `Put` подменяется заведением новой строки** (`text_repo.go:33-42,
92-101`, было minor/high у specs и code). Код не различает `sql.ErrNoRows` от отказа
базы: на кратком отказе хранилища вместо обновления заводится вторая строка текста.
Зонда никто не снял — понижено до гипотезы, но починка дешёвая и очевидная
(`errors.Is(err, sql.ErrNoRows)`).
- **Приговор шага выносится с первой попытки, и `maxAttempts` не работает никогда**
(secция «поведение вне спеки» прохода specs). Отказ `ffmpeg` идёт через `failStep`
сразу, то есть счётчик попыток не доживает до сторожа. Замера нет, дельтой вопрос
«какие отказы приговор, а какие повтор» не решён вовсе — это **самый весомый из
отложенного**: если гипотеза верна, кратковременный отказ внешней программы хоронит
запись человека с первой попытки. Стоит зонда в следующем прогоне либо строки в
дельте `pipeline`.
- **Признак «работы нет» сервис выдаёт сам за прогоны, в которых работа была**
(`transcribe.go:244,258,319,332`). Дельта требует, чтобы признак рождался только
ответом хранилища на опрос. Последствие — искажение наблюдаемости, не поведения;
зонда нет.
- **Пул без верхней границы** (замер ops: 12.6k оп/с не растёт с N, латентность
78.7µs → 7.93ms при 1→100 воркерах; `Validate()` проверяет только `Workers<0`).
Замер настоящий, но `docs/review.md`, «Недоступно проверке», прямо говорит: реального
профиля нагрузки у проекта нет, «утверждения о росте остаются условиями». Проект
работает на единицах записей в день, ущерб сегодня нулевой — гипотеза, не находка.
- **Ручная правка рубежа в панели у остановленной записи не снимает признак остановки**
(секция specs). Путь не построен, поведение панели проверено только чтением.
- **Пауза при переходе на `submitted` нигде не нормирована** и **мёртвая пара
`location`/`object_key`** (секция specs). Расхождения без названного последствия.
- **Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет ни одного, `FindAndAcquire` не
принимает контекст** (ops). Не находка этого изменения: отсутствие таймаутов
записано в `docs/review.md`, «Вопросы по темам», чтением от 2026-08-13 и старше
задачи. Названо, чтобы не читалось как новое.
## Promote candidates
- **Сканер на пару «правило домена ↔ его повтор в адаптере».** `panel.go:33-99`
повторяет `entity.AudioRecord.Resume()` колонками, а `grep '\.Resume()'` даёт
единственное вхождение — в тесте. `internal/archrules` такую пару не держит, и
разойдутся они молча. Правило механизируемо — значит это кандидат в сканер, а не
находка ревью (контракт находок, `nit`).
- **Правило конвенции для новых `select`-перечислений.** Пять новых перечислений
закрыты схемой, `docs/conventions/database.md` даёт изъятие только для `state`, ни
одной строки «*Расхождение:*» не добавлено. Либо изъятие расширяется, либо каждая
новая строка объявляется — сегодня не сказано ни то, ни другое.
- **`schemaFieldNames` в `internal/archrules` собирает поля из всех коллекций каталога
шагов, включая снесённую `transcribe_jobs`.** Правило остаётся зелёным и при колонке,
объявленной в чужой коллекции, — то есть страж инварианта про колонки ослаб. Это
правка самого правила (не «проверка над проверкой», запрет `CLAUDE.md` сюда не
достаёт), но она сама себе кандидат в конвенцию: перечень схемы читается по текущей
схеме, а не по истории каталога.
## Урожай (отложено, сработал потолок)
Ни один пункт ниже не выброшен — они не поместились в семь и ждут своей задачи.
- **Опрос чужой операции пишется на `INFO` двумя строками за цикл**
(`transcribe.go:623,637`). `docs/conventions/logging.md:60,77,183` называет «проверку
готовности операции распознавания» уровнем `DEBUG` поимённо; ≈1440 строк `INFO` на
часовую запись. Плюс записанное там же *Расхождение*: `DEBUG` включить нечем, уровень
зашит в `main.go`. Починка — одна замена уровня, но она без предмета, пока уровень не
настраивается.
- **Правило возврата записи в работу написано дважды** (`panel.go:33-99` против
`entity.AudioRecord.Resume()`), и событие журнала пишется двумя способами —
`appendEvent` через контракт и `appendResumeEvent` вручную через `core.NewRecord` в
адаптере. Механизируемая часть ушла в promote выше; остаток — решение, какой из двух
путей настоящий.
- **Поверхность без вызывающих:** `contract.Clock` (реализаций нет), `entity.Stages()`,
`entity.WorkingStates()`, поле `recordRepo` в `TelegramController`, интерфейс `Worker`
с единственной реализацией.
- **`docs/conventions/logging.md:103` называет поле `job_id`, код перешёл на
`record_id`.** Записанная конвенция разошлась с кодом — дрейф документа.
- **`design.md` изменения называет ключи `own_work_limit`/`foreign_work_limit`, код и
канон — `own_work_limit_minutes`/`foreign_work_limit_minutes`.** Дрейф документа,
дом — `av-dev:doc-healthcheck`.
- **Словарь «job» пережил понятие** (`JobNotFoundError`, `CreateJobFromApi`,
`TranscribeHandler.CreateTranscribeJob`). **Выброшено как вкусовщина**, а не отложено:
поведения не меняет, стоимости следующего изменения не меняет заметно, записанной
конвенции не нарушает; публичные имена полей API трогать всё равно нельзя. Названо,
чтобы не всплыло третьим прогоном как новое.
---
## Границы покрытия
**План: темы, дома, глубины.** requirements (`openspec/specs/` + дельты, разбор),
autotests (`CLAUDE.md` «Гейт», глубины нет), conventions (`docs/conventions/`, разбор),
architecture (`docs/architecture.md` + `passport.md`, доказательство), security
(`docs/security.md`, доказательство), operations (`docs/architecture.md`
«Эксплуатация» + `database.md`, доказательство), темы проекта — **дома нет**.
**Что запускалось.** Шесть проходов на метке `large`, режим «по графу»: specs,
autotests, code, architecture, adversary, ops. **Не запускался** `basics` — своих тем
у проекта нет, все документы `docs/` разошлись по шести темам ядра; это решение плана,
а не отказ прогона. Пятая строка про метку `small` неприменима: метка `large`, дома
`security`, `operations` и `architecture` открывались.
**Что не мог проверить каждый проход.** Уставы проходов мне дословно не переданы —
границы ниже выведены из их же выводов, и это деградация строкой: `autotests` судит
наличие и способность проверок падать, но не правильность самой нормы; `specs` судит
код против дельт и молчит там, где дельта молчит (раздел «поведение вне спеки» — ровно
этот остаток); `code` читает и не запускает боевых сценариев; `architecture` судит
форму решения и не мерит; `adversary` строит пути в границах прогона — настоящих
Telegram, SpeechKit и Object Storage в прогоне нет; `ops` мерит на своей машине и
одном каталоге данных, а не на сервере.
**Что осталось целиком на человеке** (`docs/review.md`, «Недоступно проверке»; списки
раздельные и не сливаются).
*Не проверит ни один проход:*
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и
Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; проект работает на единицах записей в день,
и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и её правило на нашего клиента;
- `security`: поведение браузера с куками — `SameSite`, приём `Set-Cookie` при переходе
с чужого сайта.
*Перестали проверять сознательно:*
- `autotests`: разбор вывода настоящего `ffprobe` — длительность даёт подставной
источник (ADR-2026-08-11-stub-adapters-in-tests);
- работа сервиса с настоящими внешними собеседниками: живой прогон отвечает за подъём,
отказ старта, маршруты и остановку; приём из Telegram, расшифровку и заливку он не
проверяет — боевым токеном запускаться запрещено, ключи Yandex выдуманы,
распознавание подменяется в коде.
Сверх этого никем не проверено: история инцидентов этого сервиса, поведение под
реальным потоком, поведение внешних систем в их сегодняшних версиях, завязка
потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
**Каких документов не хватило** — строкой на каждый, с причиной:
- `docs/security.md` **есть, но не тронут этим изменением**: периметр в нём описан по
прежней модели (задачи и файлы), про шесть коллекций и вторую раскладку файла на
диске он не знает. Проход `adversary` судил новый периметр по старому документу;
- `design.md` изменения расходится с каноном и кодом по именам ключей конфига —
документ был, но как источник имён недостоверен;
- `docs/conventions/database.md` даёт изъятие только для `state`: как объявлять новые
`select`-перечисления, не сказано ни в одну сторону, и проход `code` судил по
умолчанию;
- дельта `pipeline` не решает, какие отказы приговор, а какие повтор, — из-за этого
самая весомая гипотеза осталась гипотезой;
- `docs/review.md`, «Типовые ложноположительные», **был и использован**; раздел не
пуст, отсев шёл не вслепую.
**Сработавшие потолки — по проходу.** `code` объявил свой: конвенций 4 из 4, потолок
сработал ровно, что осталось за срезом — не названо. `architecture` объявил: 3 находки
плюс секция «дешевле переделать до мерджа». `autotests` (3), `specs` (6 + 9), `ops`
(5 + ответы на 9 вопросов) и `adversary` (2 пути + 1 свойство) **своего потолка не
сообщили** — то есть «находок больше нет» у них неотличимо от «больше не поместилось».
Это ровно то, что записано в `docs/review.md` от 2026-08-13, и класс всплыл снова.
**Потолок триажа.** В первые две секции не влезло семь причин; все они выписаны в
«Урожай» и «Гипотезы» поимённо, молча не выброшено ничего. Одна выброшена как
вкусовщина и названа там же.
**Четыре строки, которые не принёс ни один проход:**
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не
открывает. Расхождение изменения с записанным решением (в том числе с
ADR-2026-08-11 про архив и ADR-2026-08-12 про сессию) ловит скилл
`av-dev:doc-healthcheck`, а не ревью.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` не
открывался. Всякое число в этом отчёте снято проходом на этом прогоне или прочитано
в коде и каноне со ссылкой на строку.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.**
Различение «идиоматично против распространено» не спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.**
«Не знаю, чего не знаю» здесь не достаёт никто — на изменении с меткой «незнакомое»
это самый дорогой пробел прогона.
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: `critical`
в нём нет потому, что ни одна находка не получила оракула, поднимающего её до
нарушения инварианта необратимого класса, — а не потому, что таких свойств не искали
и не нашли.