# ADR-001 (ORCH-073): SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`

- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
- **Связь:** усиливает/чинит ORCH-071 (merge-verify под-гейт). Сквозной аналог — `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md` (amends adr-0013).
- **Источники:** `01-brd.md` (root-cause git-аудит 08.06), `02-trz.md` (FR-1…FR-5), `03-acceptance-criteria.md` (AC-1…AC-11).

## Контекст

Код «задеплоенных» и переведённых в `done` задач **ORCH-067** (`plane_issue_link`, кликабельные
ссылки, tracker bump) и **ORCH-069** (`qg0_title_max`) физически отсутствовал в `origin/main`,
хотя обе прошли весь конвейер, Confirm Deploy, merge-verify `CONFIRMED` и стали `done`. В `main`
попадали только их **docs-коммиты** (staging/deploy-логи через отдельные авто docs-PR), но НЕ
код feature-веток. Внешнее проявление (нашёл Слава, 08.06): в карточке Telegram сырой номер
задачи вместо кликабельной ссылки — код ссылок есть в ветке ORCH-067, но не в `main`.

### Root cause (G4 audit) — подтверждён git-аудитом, НЕ гипотеза

1. **`verify_merged_to_main` подтверждает merge по ложному признаку.** Возвращает `True`, если
   `pr_already_merged(repo, branch)` **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
   OR-ветка `pr_already_merged` — и есть дыра.
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR.** `GET /pulls?state=all&head=<branch>` и
   `True`, если **хоть один** PR `merged==True`. Параметр `head` у Gitea для одиночной строки-ветки
   фильтрует ненадёжно → в выборку попадают авто docs-PR (staging/deploy-логи) с других веток
   (`docs/*`). Сливается docs-PR → `pr_already_merged`=`True` → `verify_merged_to_main`=`True` →
   merge-verify `CONFIRMED` → `done`, хотя **code-PR НЕ слит**. Ложно-зелёный.
3. **CHANGELOG-ребейзы — вторичный усилитель.** `auto_rebase_onto_main` при конфликте
   `CHANGELOG.md` откатывает `deploy-staging → development`; повторный ребейз ветки от старого
   `main` несёт устаревшие версии соседних файлов, которые при merge тихо затирают код-сосед
   (фантом-эффект как в ORCH-071, без конфликт-маркеров).

**G1 (восстановление кода) выполнено вручную** restore-PR #76 — `git grep` подтверждает в
`origin/main` одновременно `plane_issue_link` (8), `qg0_title_max` (3+2), `verify_merged_to_main`
(4). ORCH-073 фиксирует это в AC-1 и устраняет корень навсегда (FR-1…FR-5).

## Решение

Меняется **семантика merge-verify** (под-гейт ребра `deploy → done`, врезка `_handle_merge_verify`
в `advance_stage`, введён ORCH-071). `STAGE_TRANSITIONS`, реестр `QG_CHECKS`,
`check_deploy_status`/`_parse_deploy_status`, merge-gate (`check_branch_mergeable`),
image-freshness, схема БД (`src/db.py`) — **НЕ меняются**. Внешние HTTP-эндпоинты `src/main.py` —
**НЕ меняются**.

### Р-1 (FR-1, ядро) — `verify_merged_to_main`: SHA-в-main — единственный критерий

Подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком `origin/main`»:

```
verify_merged_to_main(repo, branch, sha) -> bool:
    if not sha:                      # пустой SHA -> неопределённо
        log warning; return False    # fail-closed (alert + HOLD)
    git fetch origin main            (timeout merge_verify_timeout_s)
    rc = git merge-base --is-ancestor <sha> origin/main
    return rc == 0
```

- **OR-ветка `pr_already_merged` удаляется** из `verify_merged_to_main`. PR-флаг больше **не
  подтверждает** merge.
- Пустой `sha` → `False` (fail-closed: alert + HOLD), как сейчас.
- never-raise: любая git-ошибка → `False` (INV-1) — фейл-клозед для `done`.

> Дизайн-выбор: вариант (б) из ТЗ §2 FR-2 — единственный источник истины «merged/done» — это
> SHA-в-main. PR-флаги остаются только как **idempotency-guard** в `merge_pr` (Р-3), не как
> подтверждение.

### Р-2 (FR-2/G2) — `pr_already_merged`: различает code-PR и docs-PR

`pr_already_merged` понижается до **idempotency-guard для `merge_pr`** (не источник истины для
`done`). Но guard обязан быть **корректным**: «слит ли именно code-PR ЭТОЙ ветки», иначе merged
docs-PR заставил бы `merge_pr` ошибочно сделать no-op и пропустить реальный merge кода.
Поэтому в цикле явный фильтр (НЕ полагаться на ненадёжный query-параметр `head`):

```
for pr in resp.json():
    if pr.merged is True
       and pr.head.ref == branch          # код именно этой feature-ветки
       and pr.base.ref == "main":         # таргет — main, не docs-база
        return True
return False
```

- Исключает авто docs-PR (другой `head.ref`, напр. `docs/*`) и PR на не-`main` базу.
- never-raise → `False` (консервативно).
- Поведение для non-self репо (enduro) не меняется (INV-5) — `merge_pr`/verify для них как раньше.

### Р-3 (FR-3/G2) — `merge_pr`: сливает именно code-ветку

`merge_pr` уже выбирает открытый PR по `head.ref==branch`; добавляется фильтр `base.ref=="main"`
при выборе PR (защита от слияния PR на чужую базу). Idempotency-guard `pr_already_merged` (Р-2,
теперь корректный) перед merge оставляем — повторный прогон не делает второй POST. Merge —
ТОЛЬКО Gitea `POST /pulls/{index}/merge`, никогда push/force-push в `main` (INV-2). После merge
единственный источник истины «слилось» — FR-1 (SHA-в-main), его проверяет `_handle_merge_verify`.

### Р-4 (FR-5/G5) — регресс-гард целостности `main` (защита навсегда)

Новая детерминированная (no-LLM) функция в `merge_gate.py`, вызывается в `_handle_merge_verify`
**ПОСЛЕ** подтверждённого SHA-в-main (Р-1) и **ДО** `update_task_stage(done)`:

```
check_main_regression(repo, branch) -> tuple[bool, str]
    # ok=True  -> регресса нет (набор маркеров цел) -> пропустить к done
    # ok=False -> маркер отсутствует -> "main regressed: <task/marker> missing"
```

**Декларативный набор маркеров** — константа в `merge_gate.py` (append-only, расширяется каждой
будущей задачей; НЕ БД, НЕ Plane — Не-цель):

```python
MAIN_REGRESSION_MARKERS = [
    # (task, marker_substring, path)
    ("ORCH-067", "plane_issue_link",      "src/notifications.py"),
    ("ORCH-069", "qg0_title_max",         "src/config.py"),
    ("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
    ("ORCH-073", "check_main_regression", "src/merge_gate.py"),
]
```

Проверка (в worktree после `git fetch origin main`): для каждого маркера
`git grep -c <marker> origin/main -- <path>`; счётчик `0` → регресс.

- **Реакция при регрессе: ALERT-only + HOLD** (`set_issue_blocked` + Telegram + Plane-коммент
  «main regressed: code of `<task>` missing»), задача **НЕ `done`**, остаётся на `deploy`. БЕЗ
  авто-отката на `development` (это инфра-дефект, не код-фолт), симметрично not-merged ветке
  ORCH-071.
- **Fail-OPEN на инфра-ошибке грепа** (намеренный trade-off): любая git/OS-ошибка самого грепа →
  `(True, "guard inconclusive: …")` → НЕ блокировать `done`. Обоснование: первичный фейл-клозед
  гейт — это SHA-в-main (Р-1); вторичный marker-grep не должен давать ложный HOLD на git-сбое.
  «Регресс» утверждается только при **детерминированном `count==0`**, не при «не смог определить».
- never-raise (INV-1). Kill-switch — новый `regression_guard_enabled` (дефолт `true`,
  переиспользует область self-hosting через `merge_verify_applies`). Non-self репо — no-op (INV-5).

### Р-5 (FR-4/G4 корень) — `.gitattributes` с `merge=union`

В корне репозитория новый файл `.gitattributes`:

```
CHANGELOG.md merge=union
```

- `merge=union` — встроенный git-драйвер, доп. конфиг хоста не требуется; проверяется
  `git check-attr merge CHANGELOG.md` → `merge: union`.
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
  сохраняются) без конфликт-маркера → ветка не откатывается в `development` и не тащит устаревшие
  версии соседних файлов.
- **Решено НЕ добавлять `docs/**/*.md merge=union`:** union годится только для строго
  append-only файлов; docs-артефакты (README, ADR, internals) регулярно **переписываются**
  построчно — union там тихо задублировал бы строки. Ограничиваемся `CHANGELOG.md`.
- Оговорка о самозагрузке: задача, ВПЕРВЫЕ вносящая `.gitattributes`, при собственном ребейзе
  ещё не получает эффект union (атрибут попадёт в `main` только после её merge). Это допустимо —
  гард действует для всех последующих задач.

## Конфигурация

| Ключ | Дефолт | Назначение |
|---|---|---|
| `merge_verify_enabled` (есть) | `true` | kill-switch всего под-гейта |
| `merge_verify_repos` (есть) | `""` | CSV; пусто → только self-hosting |
| `merge_pr_timeout_s` / `merge_verify_timeout_s` (есть) | `60` | таймауты Gitea/git |
| `regression_guard_enabled` (новый) | `true` | kill-switch регресс-гарда (Р-4); non-self → no-op |

Новый ключ задокументировать в `.env.example`. Дефолты безопасны (для non-self — no-op).

## Сигнатуры (внутренние; внешний API не меняется)

- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется (Р-1), сигнатура та же.
- `pr_already_merged(repo, branch) -> bool` — назначение/фильтр уточняются (Р-2), сигнатура та же.
- `merge_pr(repo, branch) -> tuple[bool, str]` — фильтр `base==main` (Р-3), сигнатура та же.
- `check_main_regression(repo, branch) -> tuple[bool, str]` — **новая**, never-raise, fail-open.
- `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only, не источник истины).

## Инварианты

- **INV-1** never-raise: ошибка верификации → alert/HOLD, не падение конвейера.
- **INV-2** self-hosting safety: прод 8500 не падает/не рестартится в рамках merge; merge только
  Gitea PR-API, без force-push в `main`.
- **INV-3** ручной `Confirm Deploy` (ORCH-059) сохранён.
- **INV-4** идемпотентность опирается на «SHA-в-main», а не на «любой merged PR».
- **INV-5** обратная совместимость non-self (enduro): merge/verify/регресс-гард — no-op.

## Альтернативы (отклонены)

1. **Оставить `pr_already_merged` как со-критерий verify, но фильтровать по `head/base`** —
   отклонено: PR-флаг всё равно слабее факта «SHA в main» (PR можно слить и тут же откатить
   ребейзом-соседом). Единственный надёжный критерий — предок-`main`. PR-флаг → только idempotency.
2. **`docs/**/*.md merge=union`** — отклонено (см. Р-5): тихая дубликация строк в переписываемых
   доках.
3. **Регресс-гард с авто-откатом на `development`** — отклонено: регресс соседнего кода —
   инфра-дефект merge, не код-фолт текущей задачи; реакция ALERT-only + HOLD (как ORCH-021/071).
4. **Хранить набор маркеров в БД/Plane** — отклонено (Не-цель «не менять схему БД/Plane»);
   декларативная append-only константа в коде проще и версионируется вместе с фиксом.
5. **Fail-closed на marker-grep** — отклонено: дало бы ложный HOLD при git-сбое; первичный
   фейл-клозед — SHA-в-main (Р-1), marker-grep вторичен → fail-open.

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

- **Плюс:** невозможно «`done` + прод задеплоен, а code-PR не в `main`» — единственный критерий
  `done` теперь «SHA-в-main». Ложно-зелёный по docs-PR устранён в корне (Р-1+Р-2+Р-3).
- **Плюс:** CHANGELOG-конфликты больше не откатывают ветку и не тащат устаревший код-сосед (Р-5).
- **Плюс:** регресс-гард ловит откат соседнего кода даже если SHA-в-main прошёл (Р-4).
- **Минус:** при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
  (снимается повтором; fail-closed для `done` приоритетен). Регресс-гард при git-сбое наоборот
  fail-open (не блокирует) — осознанный trade-off, SHA-в-main остаётся первичным гейтом.
- **Минус:** набор маркеров требует дисциплины — каждая значимая задача дописывает свой маркер
  (иначе гард его не защитит). Документируется в `CLAUDE.md`/README.

## Связи

- Amends: `docs/architecture/adr/adr-0013-merge-verify-gate.md` (ORCH-071) — меняет критерий verify.
- Сквозной: `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md`.
- Постмортем: `docs/history/LESSONS_2026-06-08_phantom-merge.md`, runbook
  `docs/operations/PHANTOM_MERGE_RUNBOOK.md`.
- AC: AC-1 (G1 markers), AC-2/AC-3 (Р-1/Р-2), AC-4 (Р-5), AC-5 (Р-4), AC-6 (happy-path),
  AC-7 (idempotency), AC-8/AC-9 (docs+audit), AC-10 (staging), AC-11 (self-hosting safety).
