# Отчёт ревью — change `pocketbase-storage`

Метка `large`, режим «по графу». Составил проход `review-triage`; файл записал
оркестратор — среда запрещает подагенту писать отчётные `.md`. Раздел «Исход по
находкам» дописан оркестратором после отработки и помечен как таковой.

## Сводка

- **Размер:** крупное. **Сложность:** незнакомое. **Метка:** `large` — максимум
  по обеим осям. Триггеры `docs/review.md`: «замена хранилища или переход на
  PocketBase — любой её кусок», «смена модели очереди», «изменение, трогающее оба
  входа сразу».
- **Гейт:** зелёный, проверен триажем — `task gate`, exit 0, восемь шагов.
- **Особенность прогона:** часть находок первой волны оркестратор починил до
  запуска `ops`. Каждую пометку «ПОЧИНЕНО» триаж проверял в коде.

### Сигнал о заниженной метке

Не пришёл. `review-code` возражений не заявил; `review-basics` на этой метке не
запускался. Это одно молчание и одно отсутствие, а не подтверждение двумя
источниками.

### План разметки задачи с исходом по каждой теме

| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты | разбор | `specs` | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт», «Инварианты» | — | `autotests` | **закрыта**, 3 находки |
| conventions | `docs/conventions/` | разбор | `code` | **закрыта**, 9 находок + 3 за срезом потолка |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | **закрыта**, 7 находок |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 5 находок |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | **закрыта**, 3 находки + 3 замера |

**Темы без отчёта нет.** `basics` не запускался по условию состава при метке
`large` — темы он не уносил.

Отдельно: результатов **ревью дизайна** на вход триажа не подавали. Сошлись ли
ответы автора о трёх формах решения с построенным — на этом прогоне не проверено
никем.

### Счёт находок

36 пунктов от шести проходов → 27 причин после дедупликации → 12 починено до
`ops` (одна частично), 1 снята как неверная, 14 осталось плюс 1 новая, вскрытая
проверкой починок. В отчёте: 3 блокирующих, 3 «исправить сейчас», 6 гипотез,
3 promote.

### Проверка починок на месте

| находка | чем проверено | вердикт |
|---|---|---|
| `MaxSize: 0` = 5 МиБ (specs-1 = code-1 = adversary-2) | `entity.MaxRecordSize = 8 << 30` у поля файла и у тела приёма; тест судит границу `5<<20 + 1` и способен упасть | полна |
| хук панели правил записи конвейера (specs-2 = code-2 = adversary-3 = architecture-1) | `OnRecordUpdateRequest`; два теста, включая обратный | полна в объявленном объёме; остаток — блокер 2 |
| 413 на 32 МиБ и `ReadTimeout` 5 минут | `apis.BodyLimit(entity.MaxRecordSize)` на маршруте, `ReadTimeout = 0` | полна |
| гонка `srv` | буферизованный канал, чтение `select`/`default` | полна |
| `file` без `Required` | `Required: true`, шаг схемы на сервер не уезжал | полна |
| спека утверждала про суффикс имени | прогон: имя в хранилище — ровно заданное, суффикса нет | полна |
| рабочая копия без расширения | `Stage(ext string, …)`, приём передаёт расширение | полна |
| инвариант «колонка в N местах» | возвращён в `CLAUDE.md` с перечнем мест | полна |
| две записи ERROR на один отказ приёма | лог из обработчика снят | полна |
| расхождения с `conventions/database.md` | обе строки на месте | полна |
| **вечная ссылка на чужое аудио через журнал** | прогон: `Open` и `Localize` отдают текст с идентификатором записи, имени файла в нём нет | путь А закрыт; путь Б закрыт наполовину — см. «Стоит исправить», п. 2 |
| пустой держатель в `Save` | комментарий исправлен, ветка оставлена | частично, и этого достаточно |

**Снято как неверное:** замечание `architecture` про ключ объекта в Object
Storage. Оно стояло на посылке о суффиксе имени, которой нет: имя в хранилище и
есть `<uuid><расширение>`, значит утверждение `architecture.md` верно.

## Блокирует мердж

### 1. Образ не собирается: сборщик `golang:1.24`, а модуль требует Go 1.25

- Файл: `Dockerfile:2`, `go.mod:3`
- Severity: major, Confidence: high
- Оракул: сборка в контейнере `golang:1.24-alpine` → `go.mod requires go >= 1.25.0
  (running go 1.24.13; GOTOOLCHAIN=local)`; на `golang:1.25-alpine` → успех.
  Директива не наша: `pocketbase@v0.39.10/go.mod` объявляет `go 1.25.0`.
- Последствие: `task image` падает — выкладки change нет вовсе. Гейт этого не
  показывает: образ он не собирает, а `go build` идёт на хостовом Go.
- Найдено: **никем** — вскрылось проверкой починок (триаж).
- Действие: инлайн

### 2. Правка задачи в панели пропадает при ближайшем сохранении шага

- Файл: `transcript_job_repo.go`, `job_mapping.go`, `panel.go`
- Severity: major, Confidence: high
- Оракул: временный тест — задача с `tg_chat_id = 111` захвачена шагом, правится
  через `PATCH /api/collections/transcribe_jobs/records/<id>`, затем шаг
  сохраняет снимок. Итог: `expected 999999 / actual 111`.
- Последствие: `Save` сторожил только `acquisition_id`, а запись клала **все**
  поля снимком с момента захвата. Окно — до восьми часов. Ни строки в журнале,
  ни отказа в панели. Панель ради правки и покупалась.
- Найдено: `ops`.
- Действие: развилка

### 3. Два из трёх шагов конвейера переписаны и не выполнены ни одним тестом

- Файл: `internal/service/transcribe.go` — `transcribeJob`, `checkTranscribeJob`,
  `completeJob`
- Severity: major, Confidence: high
- Оракул: покрытие с `-coverpkg=./...` — 0.0% у всех трёх и у обоих
  `FindAndRun*`; `git diff --stat` по файлу — 268 вставок, 192 удаления.
- Последствие: путь «отдать запись на распознавание → дождаться операции →
  ответить пользователю» прошёл замену хранилища без единого выполнения.
- Найдено: `autotests`.
- Действие: инлайн

## Стоит исправить сейчас

### 1. Документы обещают, что рабочую копию убирает хранилище; убирает вызывающий

- Severity: minor, Confidence: high. Найдено: `architecture`. Действие: развилка.
- `architecture.md`, `design.md` и дельта-спека `storage` требуют, чтобы уборку
  делало хранилище. В коде копию закрывает вызывающий тремя `defer`. Дефекта в
  коде нет — расхождение в записи, которая уедет в архив.

### 2. Отказ заливки в Object Storage больше не отличим от любого другого

- Severity: minor, Confidence: high. Найдено: **никем** — вскрылось проверкой
  полноты починки. Действие: инлайн.
- Починка приватности оборвала цепочку `%w` целиком: отозванные ключи,
  исчезнувший бакет и отказ сети дают одну строку.

### 3. Входящие HTTP-запросы исчезли из журнала процесса

- Severity: minor, Confidence: high. Найдено: `code`. Действие: развилка.
- `sloggin` ушёл вместе с gin; `activityLogger` PocketBase пишет в свою таблицу.
  Строка «*Расхождение:*» в `logging.md` указывает на удалённую библиотеку.

## Гипотезы без доказательства

1. Ответ SpeechKit об отказе операции может нести S3-URI и уехать в журнал и в
   `error_text`. Оракул требует настоящего отказа Yandex — запрещено `CLAUDE.md`.
2. Имя файла в хранилище принимает почти любой хвост имени отправителя. Выхода за
   каталог **нет** (15 враждебных имён). Станет находкой с появлением экрана
   списка записей.
3. `entity.File.FileName` несёт два смысла — имя в хранилище и ключ объекта.
   Сегодня они совпадают дословно.
4. Отмена контекста структурно невозможна: `RunInTransaction`/`FindRecordById`
   контекста не принимают. Замер: под внешней блокировкой захват держится 9.53 с
   при `busy_timeout=10000`. Уточнение уже объявленного долга.
5. Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет. Задача заведена.
6. Приём пишет тело на диск дважды. Не замерено; нагрузка объявлена вне модели.

Ниже потолка названы и не потеряны: `LostAcquisitionError` типом там, где
конвенция просит sentinel; форма обёрток `"failed to <действие>: %w"`; уровень
`ERROR` с инкрементом метрики на потерянном захвате против `WARN` конвенции;
панель как третий вход в таблицу задач.

## Promote candidates

1. **`govulncheck ./...` шагом гейта.** Сегодня его нет ни в гейте, ни в списке
   «чего в гейте намеренно нет». Достижимых уязвимостей две, унаследованных:
   `GO-2026-6061` (grpc), `GO-2026-5764` (aws eventstream, s3).
2. **Сверка версии Go в `Dockerfile` с директивой `go` в `go.mod`.** Блокер 1
   прошёл весь конвейер молча именно потому, что образ никто не собирает.
3. **Покрытие изменённых строк.** Блокер 3 — второй случай подряд, когда его
   ловят руками.

## Границы покрытия

- Запускались: `specs`, `code`, `architecture`, `adversary`, `autotests` — по
  коду **до** починок первой волны; `ops` — по коду **после**. Находки пяти
  первых перепроверены триажем поимённо.
- `basics` не запускался: при метке `large` темы разобраны именными проходами,
  своих тем проекта нет.
- Независимой реализации для сравнения не строил никто — прохода нет в конвейере.
- Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
  Для change, который впервые в проекте пишет на PocketBase, это самый дорогой
  пробел.
- **Блоки `Coverage of this pass` до триажа дошли не от всех проходов.** О своих
  потолках сообщил только `code` (конвенционная половина, 4, за срезом три
  находки — перенесены). Остальные не сообщили; это находка о прогоне.
- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`) прогон
  не открывает — процессные документы. Расхождение change с записанным решением
  ловит не ревью, а сверка документации (`av-dev-docs:healthcheck`).
- Не проверит ни один проход: поведение SpeechKit и Object Storage под нагрузкой
  и на границах; реальный профиль нагрузки; стойкость `ffmpeg` к вредоносному
  входу.
- Перестали проверять сознательно: разбор вывода настоящего `ffprobe` — решение и
  цена в `ADR-2026-08-11-stub-adapters-in-tests.md`.
- Каких документов не хватило: `docs/conventions/web-ui.md` описывает будущую
  SPA, а не панель — судить панель против конвенции было нечем; `docs/research/`
  по весу шестичасовой записи пуст, оценки читались как оценки.

## Исход по находкам

*Раздел дописан оркестратором после отработки урожая; в отчёте триажа его нет.*

**Починено:**

- блокер 1 — `Dockerfile` переведён на `golang:1.25-alpine`, строки про Go 1.24 в
  `CLAUDE.md` и `README.md` исправлены; сборка в контейнере проверена;
- блокер 2 — запись шага разделена: `applyOwnedByPipeline` кладёт только поля
  конвейера, `applyToRecord` целиком остаётся заведению. Заведён тест
  `TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob`; норма дописана в дельта-спеку
  `pipeline` со сценарием;
- блокер 3 — заведён управляемый двойник распознавателя и восемь проверок
  (`internal/service/recognition_test.go`). Покрытие: `transcribeJob` 0 → 67.6%,
  `checkTranscribeJob` 0 → 72.4%, `completeJob` 0 → 100%, оба `FindAndRun*` → 100%;
- «стоит исправить» 2 — класс отказа SDK сохраняется через `smithy.APIError`,
  адрес объекта — нет;
- «стоит исправить» 3 — журнал входящих запросов вернулся своим слоем в `main.go`
  с пятью полями конвенции, `/health` и `/metrics` на `DEBUG`; расхождение в
  `logging.md` снято.

**Решено развилкой, вариант назван человеку на чекпоинте:**

- «стоит исправить» 1 — норму привели к коду: хранилище даёт единственный способ
  убрать копию, зовёт его шаг. Замкнуть уборку на репозиторий мешает конвертация:
  ей нужны две копии разом. Цена названа — норма держится проверками, и проверки
  заведены на приём и на шаг конвертации.

**Оставлено, передано урожаем:** все шесть гипотез и четыре находки ниже потолка;
три кандидата в promote.
