---
work_item: ORCH-027
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-10
model_used: claude-opus-4-8
---

# ADR-001: Гейт покрытия тестами — edge sub-gate с ratchet-базовой линией

Work Item: **ORCH-027** — детерминированный гейт покрытия тестами, блокирующий деградацию
покрытия перед слиянием ветки задачи в `main`.
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0029-coverage-gate.md`** (решение
кросс-каттинговое — вводит новый QG `check_coverage_gate`, новый edge-под-гейт ребра
`deploy-staging→deploy`, новую аддитивную БД-таблицу `coverage_baseline` и новый артефакт
`18-coverage-report.md`).

## Статус
Proposed

---

## Контекст

Оркестратор ведёт **автономную** разработку: код пишет агент `developer` без человека-фильтра,
а на стадии `testing` агент `tester` сам решает, достаточно ли тестов. Существующие тестовые
гейты судят только по **факту прохождения**, не по **полноте** (сверено по коду):

- `check_ci_green` (`development → review`) — exit-code `pytest tests/` в Gitea CI
  (`.gitea/workflows/ci.yml`); покрытие не меряется.
- `check_tests_passed` (`testing → deploy-staging`, `qg/checks.py::_parse_tests_verdict`) —
  читает machine-verdict LLM-`tester`'а из `13-test-report.md`, а не измеренную метрику.
- Merge-gate re-test (ORCH-043, `src/merge_gate.py`) — повторный `pytest` на догнанной ветке,
  снова только exit-code.

Ни один гейт не замечает «300 строк кода, 0 тестов» или багфикс без регрессионного теста. При
пакетном автономном прогоне (ORCH-088, «10–20 задач за ночь») это означает **монотонную
деградацию покрытия**: каждая задача срезает угол на тестах, и за десятки задач проект тихо
теряет тестируемость. Нужна детерминированная метрика вместо доверия суждению агента — по духу
аналогично security-гейту (ORCH-022, adr-0012).

Требования (`01-brd.md`/`02-trz.md`/`03-acceptance-criteria.md`): измерять покрытие
инструментально перед merge в `main` (BR-1/FR-1); блокировать деградацию относительно
абсолютного порога и/или базовой линии (BR-2/BR-3/FR-2); хранить и наращивать базовую линию
(ratchet, FR-4); kill-switch + per-repo область, нулевая регрессия для enduro-trails
(BR-4/FR-5); fail-open по умолчанию при сбое инструмента (NFR-2/FR-6); never-raise и
self-hosting-безопасность (NFR-1/NFR-3); неизменность существующих контрактов (NFR-5).

## Решение

### Сводка

Вводим **детерминированный (без LLM) гейт покрытия** как **под-гейт ребра
`deploy-staging → deploy`** — рядом с security-gate (ORCH-022), merge-gate (ORCH-043) и
image-freshness (ORCH-058), исполняемый **ПОСЛЕ merge-gate и ДО image-freshness**.
`STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавляется `check_coverage_gate`. Паттерн —
1:1 как у соседних под-гейтов: leaf-модуль `src/coverage_gate.py` (never-raise) + тонкая
обёртка в `QG_CHECKS` + врезка `_handle_coverage_gate` в `advance_stage`. Базовая линия `main`
хранится в **аддитивной БД-таблице** `coverage_baseline` и наращивается **вверх** (ratchet) в
choke-point подтверждённого merge `_handle_merge_verify` (ребро `deploy → done`). Вердикт
пишется в артефакт `18-coverage-report.md` (frontmatter-ключ `coverage_status:`) и читается
обратно из того же файла (single source of truth, как `security_status:`).

### D1 — Точка в конвейере: edge sub-gate `deploy-staging → deploy`, ПОСЛЕ merge-gate (FR-3a)

Из трёх кандидатов TRZ FR-3 выбран **(a) edge sub-gate** на ребре `deploy-staging → deploy`
(`advance_stage`, `src/stage_engine.py`, блок `current_stage == "deploy-staging"`). Это даёт
структурную гарантию «гейт ДО merge в `main`» (merge выполняется детерминированным merge-актором
в `_handle_merge_verify` на ребре `deploy → done`), детерминизм и владение исходом на
вмешательстве — полное соответствие NFR-3/NFR-6.

**Порядок среди под-гейтов: security → merge → `coverage` → image-freshness.** Обоснование:

- **ПОСЛЕ merge-gate (а не первым, как security).** Merge-gate выполняет догон ветки на свежий
  `origin/main` (`auto_rebase_onto_main` под merge-lease, ORCH-043/026). Покрытие имеет смысл
  мерить на **догнанном** HEAD — это ровно тот код, что landed в `main`; измерение до rebase
  показало бы покрытие устаревшей базы. Поэтому coverage **обязан** идти после merge-gate
  (в отличие от security, который специально фейлит дёшево ДО rebase).
- **ДО image-freshness.** Прогон pytest под coverage дорог, но дешевле полного docker-rebuild
  staging-образа. Фейлить покрытие до rebuild — экономия (паттерн «fail before expensive
  rebuild», 07-infra security-гейта).
- **Merge-lease held на этой точке.** Merge-gate уже захватил merge-lease (ORCH-043). Значит
  **FAIL coverage обязан освободить merge-lease** при откате — как делает image-freshness
  rollback (`merge_gate.release_merge_lease`, `stage_engine.py:1165`), и **в отличие** от
  security-gate rollback (тот идёт ДО захвата lease и lease не трогает). Это явный инвариант
  реализации (TR-2).

Привязка: BR-2/FR-3/AC-2; NFR-3/AC-7.

### D2 — Измеритель: `pytest-cov` (`coverage.py`), `--cov=src` (FR-1, BR-6)

В `requirements.txt` добавляется **`pytest-cov`** (плагин-обёртка над `coverage.py`). Измерение —
прогон `python -m pytest tests/ --cov=src --cov-report=json:<tmp>/coverage.json
--cov-report=` в изолированном per-branch worktree (`ensure_worktree`, прецедент
`check_tests_local`/merge-gate re-test). Числовая метрика — `totals.percent_covered` из JSON
(line coverage, `%`). Скоуп измерения — **`src/`** (не `tests/`: покрытие самих тестов вне
объёма, BRD §«Вне объёма»). Сеть при измерении не нужна. Тайм-аут — `coverage_run_timeout_s`
(по образцу `merge_retest_timeout_s`/`security_scan_timeout_s`).

**Стек-расширяемость (BR-6/AC-… BR-6):** измеритель инкапсулирован за функцией
`measure_coverage(repo, branch) -> float | None`; чистая логика решения
`compute_coverage_verdict(...)` и хранилище базовой линии **не зависят** от Python/pytest.
Добавление jest/jacoco-измерителя для будущего стека — новая ветка `measure_*`, без переписывания
ядра. Фактическая интеграция не-Python стеков — вне объёма ORCH-027.

### D3 — Чистая функция решения (FR-2, NFR-6, BR-2/BR-3)

`compute_coverage_verdict(measured, baseline, floor, policy, epsilon) -> (ok: bool, reason: str)` —
детерминированная чистая функция (без LLM, без I/O):

- `policy = "absolute"` → PASS ⇔ `measured >= floor - epsilon`.
- `policy = "baseline"` → PASS ⇔ `measured >= baseline - epsilon`.
- `policy = "both"` (дефолт) → PASS ⇔ выполнены **оба** условия.
- `baseline is None` (нет сохранённой базовой линии) → baseline-условие **не применяется**
  (bootstrap: нельзя регрессировать против пустоты) → решает только absolute-часть; измеренное
  значение засеет базовую линию при merge (D5).
- `epsilon` — малый неотрицательный допуск на шум измерения (NFR-4/AC-4): дрожание ±доли
  процента у границы не заворачивает задачу.

FAIL → штатный откат на `development` + инкремент общего `_developer_retry_count` (cap
`MAX_DEVELOPER_RETRIES`, затем `set_issue_blocked` + Telegram) — точно как security/merge-gate
rollback. Дословный reason (измеренное/порог/базовая линия/дельта) встраивается в `task_desc`
developer'а (паттерн ORCH-046). Привязка: AC-2/AC-3.

### D4 — Хранилище базовой линии: аддитивная БД-таблица `coverage_baseline` (FR-4, NFR-5)

Базовая линия `main` хранится в **БД**, не в файле репозитория:

```sql
CREATE TABLE IF NOT EXISTS coverage_baseline (
    repo        TEXT PRIMARY KEY,
    coverage    REAL NOT NULL,
    source_sha  TEXT,
    updated_at  TEXT NOT NULL
);
```

(паттерн `repo_freeze`/`job_deps` — `CREATE TABLE IF NOT EXISTS`, существующие таблицы не
мигрируются, NFR-5/AC-8; детали — `08-data-requirements.md`). **Почему БД, а не файл в репо**
(`.coverage-baseline.json`): файл пришлось бы коммитить в `main` на каждый ratchet → git-churn,
сам файл попадает в diff и может конфликтовать при параллельных merge, плюс он часть измеряемого
дерева. БД-таблица — restart-safe, аддитивна, обновляется атомарно и не порождает коммитов.
Таблица keyed by `repo` → общая прод-БД (self-hosting) безопасно разделяет базовые линии разных
репозиториев.

### D5 — Ratchet-up в choke-point подтверждённого merge (FR-4, BR-3)

Базовая линия наращивается **только вверх** и **только при подтверждённом** слиянии в `main`.
Единственный авторитетный choke-point подтверждённого merge — `_handle_merge_verify` (ребро
`deploy → done`, ORCH-071/073, доказательство SHA-in-main). Туда добавляется never-raise врезка
`coverage_gate.ratchet_baseline_on_merge(repo, work_item_id, branch, sha)`, вызываемая **после**
того как merge подтверждён (`_handle_merge_verify` вернул `False` = confirmed) и **до** перехода
в `done`:

1. Читает измеренное покрытие смёрженной ветки из артефакта `18-coverage-report.md` (single
   source of truth — то же значение, что гейт записал на ребре `deploy-staging→deploy`).
2. **Атомарный compare-and-set:** `UPDATE coverage_baseline SET coverage=?, source_sha=?,
   updated_at=? WHERE repo=? AND coverage <= ?` (или `INSERT` при отсутствии строки —
   bootstrap). Условие `coverage <= measured` гарантирует, что базовая линия **никогда не
   падает** (FR-4), даже при гонке.

**Сериализация (анти-гонка, NFR-5/AC-4):** на этой точке merge-lease ещё **held** (release на
`done`/rollback, `stage_engine.py:446`), а merge репо сериализован per-repo (ORCH-043). Плюс
атомарный compare-and-set в SQL — **двойная защита**: даже без lease два параллельных merge не
понизят и не потеряют значение. Bootstrap — первый merge применимого репо засевает базовую линию
своим измеренным покрытием.

### D6 — Условность, kill-switch, наблюдаемость (FR-5/FR-7, BR-4/BR-5)

- **Флаги (`config.py`, env `ORCH_COVERAGE_*`):** `coverage_gate_enabled` (bool, kill-switch),
  `coverage_gate_repos` (CSV; **пусто → только self-hosting** `is_self_hosting_repo`, по образцу
  `merge_gate`/`security_gate`/`image_freshness`), `coverage_min_percent` (float, абсолютный
  порог-floor), `coverage_policy` (`absolute|baseline|both`, дефолт `both`), `coverage_epsilon`
  (float, дефолт малый, напр. `0.5`), `coverage_tool_fail_closed` (bool, дефолт `False`),
  `coverage_run_timeout_s` (int).
- **`applies(repo)`** (локальная проверка) выполняется **ПЕРВОЙ**; дорогой прогон измерения —
  только при `applies==True`. Вне области → no-op `(True, "Coverage gate N/A")` (прецедент
  `check_staging_status` для не-self, ORCH-035). При `coverage_gate_enabled=False` — гейт инертен,
  конвейер 1:1 как до ORCH-027 (AC-5).
- **FR-6 (ошибка инструмента):** `measure_coverage` вернул `None` (инструмент упал/недоступен/
  метрика не распарсилась) → по умолчанию **fail-open + WARNING** (observability-строка),
  `coverage_tool_fail_closed=True` → fail-closed (FAIL). Дефолт анти-петля (прецедент
  ORCH-061/ORCH-022 dep-audit), чтобы инфра-сбой не заклинил автономный конвейер.
- **FR-7 (наблюдаемость):** артефакт `18-coverage-report.md` (frontmatter `coverage_status:
  PASS|FAIL` + `measured_coverage`/`baseline`/`floor`/`policy`/`delta`); read-only блок
  `coverage` в `GET /queue` (`src/main.py`); при FAIL — `send_telegram` с кликабельным номером
  (`plane_issue_link`/`link_for`), измеренным покрытием, порогом/базовой линией и дельтой.

### D7 — Машинный вердикт и парсинг (§6 ТЗ, AC-9)

Гейт **сам вычисляет** вердикт (как `check_security_gate`) и пишет
`18-coverage-report.md` с YAML-frontmatter `coverage_status:` (`PASS` | `FAIL`); регистр
чувствителен, имя фиксируется. Чтение обратно — через единый контракт `src/frontmatter.py`
(`parse_frontmatter`/`read_frontmatter_value`, ORCH-052c), как все вердикт-парсеры. Артефакт
несёт **аддитивно** обязательную 6-польную схему 52c, не трогая `coverage_status:`. В `QG_CHECKS`
добавляется `check_coverage_gate` (тонкая обёртка, делегирующая в leaf); **семантика и состав
существующих `check_*` / machine-verdict ключей (`verdict:`/`result:`/`deploy_status:`/
`staging_status:`/`security_status:`) — байт-в-байт прежние** (NFR-5/AC-8).

### D8 — Опциональный override базовой линии (FR-4 / §4 API)

Для легитимного разового снижения покрытия (напр. удаление большого протестированного модуля)
вводится опциональный ручной эндпоинт `POST /coverage/baseline?repo=<repo>&value=<float>` (по
образцу `POST /serial-gate/unfreeze`) — устанавливает/сбрасывает базовую линию вручную.
Альтернатива без эндпоинта — временно переключить `coverage_policy=absolute`. Эндпоинт
рекомендован для эксплуатационной гибкости, но не критичен для v1.

## Альтернативы

- **Точка измерения — CI-job (`check_ci_green`, FR-3c).** Пороги/политика/базовая линия/артефакт
  плохо выражаются статусом коммита; ratchet требует записи в общую БД, недоступную из CI-раннера
  чисто. Коуплинг с раннером. Отклонено для v1 (точка расширения), как у security-гейта.
- **Точка измерения — `testing → deploy-staging` (рядом с `check_tests_passed`, FR-3b).** Ветка
  ещё не догнана на свежий `main` → измеренное покрытие может не соответствовать landed-коду;
  откат отсюда не освобождает merge-lease иначе. Edge `deploy-staging→deploy` после merge-gate —
  точнее. Отклонено.
- **Базовая линия в файле репо (`.coverage-baseline.json`).** Git-churn на каждый ratchet,
  конфликты при параллельных merge, файл — часть измеряемого дерева. Отклонено в пользу
  аддитивной БД-таблицы (D4).
- **Складывание измерения в merge-gate re-test (один pytest-прогон).** Снижает дабл-ран, но
  коуплит coverage-логику с merge_gate; нарушает leaf-изоляцию ТЗ. Отклонено для v1 (возможный
  follow-up — измерять покрытие в том же прогоне).
- **Новый stage `coverage`.** «Пустая» стадия без агента не имеет триггера (как в ORCH-043/022).
  Отклонено.
- **Жёсткий абсолютный порог без baseline/epsilon.** Массовые ложные заворота → петля rework.
  Отклонено в пользу консервативного `both` + epsilon (NFR-4).

## Последствия

- **+** Класс «тихо просевшее покрытие» закрыт детерминированной метрикой; защита от монотонной
  деградации в пакетном автономном прогоне (ORCH-088). Базовая линия может только расти (ratchet).
- **+** Нулевая регрессия: при выключенном флаге / вне области (enduro-trails) — конвейер
  байт-в-байт прежний; `STAGE_TRANSITIONS`/`QG_CHECKS`-семантика/вердикт-ключи не тронуты.
- **+** Self-hosting-безопасно: гейт только мерит/читает/пишет/решает; не деплоит, не рестартит
  прод, не пушит/форс-пушит в `main` (NFR-3).
- **−** Дополнительный прогон pytest под coverage на каждой применимой задаче (после merge-gate
  re-test) → ещё один полный тест-ран. Митигейшн: ограничен `coverage_run_timeout_s`; фейлит до
  дорогого image-rebuild; follow-up — слияние с merge-gate re-test.
- **−** Ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); новая pip-зависимость
  (`pytest-cov`); v1 — Python-only (мульти-стек — точка расширения BR-6).
- **−** Дефолтный fail-open означает, что устойчивый сбой инструмента **тихо** пропускает задачи
  (с WARNING). Митигейшн: громкий лог + переключатель `coverage_tool_fail_closed`.
- **Сквозное изменение** (новый QG + edge-под-гейт + новая БД-таблица + новый артефакт) →
  лейбл `arch:major-change`; прод-деплой ORCH-027 — строго через staging-гейт (8501), без
  рестарта прод-контейнера.
- **Откат:** `coverage_gate_enabled=False` → полный no-op (мгновенный обратимый kill-switch).
  Полное удаление — снять врезки `_handle_coverage_gate`/`ratchet_baseline_on_merge`, удалить
  leaf-модуль, `check_coverage_gate` из `QG_CHECKS`, флаги, артефакт-шаблон; таблица
  `coverage_baseline` аддитивна и может остаться (инертна).

## Ссылки

- BRD: `docs/work-items/ORCH-027/01-brd.md`
- TRZ: `docs/work-items/ORCH-027/02-trz.md`
- Acceptance: `docs/work-items/ORCH-027/03-acceptance-criteria.md`
- Data: `docs/work-items/ORCH-027/08-data-requirements.md`
- Risks: `docs/work-items/ORCH-027/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0029-coverage-gate.md`
- Сверено по коду: `src/stage_engine.py` (`_handle_security_gate`/`_handle_merge_gate`/
  `_handle_image_freshness`/`_handle_merge_verify`), `src/security_gate.py`, `src/merge_gate.py`,
  `src/qg/checks.py`, `.gitea/workflows/ci.yml`, `pytest.ini`
- Прецеденты: adr-0012 (security-гейт), adr-0006 (merge-gate — edge-под-гейт/откат/lease),
  adr-0008 (image-freshness — условность/fail-closed), adr-0003 (`is_self_hosting_repo`),
  adr-0009 (анти-петля ложных FAIL), adr-0014 (SHA-in-main как source of truth для merge)
