--- 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:/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=&value=` (по образцу `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)