# ADR-001 — Толерантность staging-вердикта к инфра-FAIL + инвариант «no-changes на action-стадии»

- **Статус:** Accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-061 (BUG, P0) · Репо: `orchestrator` (self-hosting)
- **Связи:** ORCH-35/adr-0003 (условный staging-гейт), ORCH-36/adr-0007 (исполняемый self-deploy), ORCH-58/adr-0008 (провенанс staging-образа), ORCH-43/adr-0006 (merge-gate); блокирует ORCH-54.
- **Сквозной ADR:** [adr-0009-staging-infra-tolerance](../../../architecture/adr/adr-0009-staging-infra-tolerance.md)

---

## Контекст

На стадии `deploy-staging` self-hosting `orchestrator` зацикливается:
`check_staging_status` даёт FAILED → `_handle_qg_failure_rollbacks` откатывает
`deploy-staging → development` → developer перезапускается → конвейер заново →
снова `deploy-staging` → снова FAILED. Петля жжёт developer-ретраи и LLM-кредиты,
а прод-деплой орка приходится доводить вручную (ORCH-58, ORCH-60). Это прямой
блокер автономного внедрения (ORCH-54).

Подтверждены две независимые причины (BRD §2):

**Причина №1 — ложный FAILED.** `scripts/staging_check.py` в sandbox даёт
8/10 PASS, 2 ложных FAIL на e2e-блоке C:
- **C9a** — ветка не появляется в `orchestrator-sandbox`;
- **C9b** — analyst-job не появляется в staging-очереди.

Оба завязаны на отсутствие sandbox-настроек (bot-аккаунты `ORCH_PLANE_BOT_*` не
добавлены членами SANDBOX-проекта — проект создан после провижининга ботов). Это
**отсутствие инфраструктуры sandbox, а не регресс кода**. Но `staging_check.py`
суммирует `all_ok = passed == total` и делает `sys.exit(1)` при любом FAIL →
deployer пишет `staging_status: FAILED` → откат.

**Причина №2 — «no changes to commit» на action-стадии.** Стадии деплоя по природе
действие (рестарт/retag), а не правка `src/`. Отсутствие git-изменений не должно
трактоваться как недовыполнение; критерий успеха action-стадии — exit0 +
health/staging-вердикт, а не наличие коммита.

### Что есть сейчас в коде (точки дефекта)

- `scripts/staging_check.py`: `Results.summary()` → `all_ok = passed == total`;
  `main()` → `sys.exit(0 if all_ok else 1)`. Все проверки равнозначны — инфра-FAIL
  неотличим от регресса.
- `src/qg/checks.py` → `check_staging_status` / `_parse_staging_status`: читает
  `staging_status:` (SUCCESS|FAILED) из `15-staging-log.md`. Условный (ORCH-35):
  для не-self репо → `(True, "Staging gate N/A …")`.
- `src/stage_engine.py` → `_handle_qg_failure_rollbacks`: ветка
  `agent=="deployer" and qg=="check_staging_status"` → откат на `development`.
- `src/agents/launcher.py` → `_monitor_agent`: ветка «no changes to commit» (строка
  ~583) **уже** просто логирует и идёт в `_try_advance_stage` (НЕ откатывает).

## Рассмотренные направления (BRD §6)

- **(а) Починить sandbox-инфру** — добавить bot-токены SANDBOX, чтобы C9a/C9b
  проходили честно (10/10).
  - *Минусы:* хрупко (зависит от членства ботов в Plane-проекте, поддерживается
    руками вне кода); не предотвращает структурно будущие инфра-only FAIL;
    автономный self-deploy-таск не может надёжно выполнить Plane-admin действия сам.
    Не закрывает Причину №1 на уровне инварианта.
- **(б) Отвязать вердикт от заведомо инфраструктурных проверок** — классифицировать
  проверки suite и сделать вердикт толерантным к инфра-FAIL, сохранив fail-closed
  для реальных проверок.
  - *Плюсы:* структурно, юнит-тестируемо (чистая вердикт-логика), управляемо
    (kill-switch), наблюдаемо (FR-7); сохраняет страховку (FR-4) по построению.

## Решение

Выбран механизм **(б)** как основной, с явной фиксацией инварианта по Причине №2.
Направление (а) переведено в **необязательное hardening** (см. `07-infra-requirements.md`):
с (б) оно перестаёт быть блокером.

### 1. Классификация проверок + толерантный вердикт (Причина №1, FR-2/FR-4)

Новый **leaf-модуль `src/staging_verdict.py`** — чистая логика, без I/O, контракт
**never-raise**, только stdlib (импортируем и из orchestrator, и из
`staging_check.py`, который уже импортирует `src.*` внутри контейнера — паттерн B6/ORCH-048):

```
REAL          = "real"            # реальная pipeline-проверка
SANDBOX_INFRA = "sandbox_infra"   # заведомо зависит от sandbox-инфры

# Узкий allowlist известных инфра-проверок (по префиксу метки):
SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"})

def classify_check(label: str) -> str:
    """SANDBOX_INFRA если метка начинается с известного инфра-префикса, иначе REAL.
    Never-raise: на любом непонятном вводе → REAL (консервативно, fail-closed)."""

def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict:
    """items: список (label, passed: bool, category: str).
    real_failed  = [REAL-проверки с passed=False]
    infra_failed = [SANDBOX_INFRA-проверки с passed=False]
      - real_failed непусто                       -> FAILED, exit 1  (страховка)
      - infra_failed непусто и infra_tolerant      -> SUCCESS, exit 0 (waived)
      - infra_failed непусто и НЕ infra_tolerant   -> FAILED, exit 1  (legacy strict)
      - иначе                                       -> SUCCESS, exit 0
    Never-raise: на битом вводе → консервативный FAILED."""
```

`StagingVerdict` несёт `status` (`"SUCCESS"|"FAILED"`), `exit_code` (`0|1`),
`waived` (список заваиверенных меток) и `summary` (человекочитаемая строка).

**Ключевой инвариант страховки (FR-4):** любая упавшая REAL-проверка ⇒ exit 1 ⇒
FAILED ⇒ откат. В частности C7 (создать issue) и C8 (триггер `/webhook/plane`) —
REAL. Waiver применяется к C9a/C9b **только** когда все REAL-проверки (включая
C7/C8) зелёные. Вход в конвейер по-прежнему валидируется C7/C8; C9a/C9b проверяют
лишь downstream-артефакты, которым нужна sandbox-инфра. Так blast-radius waiver'а
сведён к двум именованным проверкам.

### 2. Правки `scripts/staging_check.py`

- `Results.add(label, passed, detail="", category=None)` — при `category is None`
  авто-классификация через `staging_verdict.classify_check(label)`; хранит категорию
  в элементе.
- `Results.summary()` печатает разбивку по категориям (REAL / SANDBOX_INFRA).
- `main()`:
  - резолвит флаг толерантности `_resolve_tolerance()` (см. ниже);
  - `verdict = compute_staging_verdict(results.items, infra_tolerant)`;
  - при `verdict.waived` печатает явную строку
    `INFRA-WAIVED: <labels> (known sandbox-infra; real checks green)` (FR-7);
  - `sys.exit(verdict.exit_code)`.
- `_resolve_tolerance()`: читает `settings.staging_infra_tolerance_enabled` (через
  `from src.config import settings` — тот же паттерн, что B6). На ошибке импорта →
  **strict (False)** (fail-safe: не вайвить при нечитаемом конфиге) + warning.
  Опциональный CLI-флаг `--strict` принудительно выключает толерантность для ручных
  «честных» прогонов.

Прежние режимы (`--mode stub|full-real`) и проверки A/B/C7/C8 — без изменений.
«Всегда 0» исключено: упавшая REAL-проверка всегда даёт exit 1 (TRZ §7).

### 3. Kill-switch (FR-6, AC-7)

`src/config.py`:
```python
# ORCH-061: толерантность staging-вердикта к заведомо инфраструктурным FAIL
# (C9a/C9b) в sandbox. True -> упавшие ТОЛЬКО sandbox-инфра проверки вайверятся
# (real-проверки fail-closed). False -> 1:1 прежнее строгое поведение (любой FAIL
# -> staging_status FAILED -> откат). Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED.
staging_infra_tolerance_enabled: bool = True
```

Дефолт **True** (как `merge_gate_enabled` / `image_freshness_enabled` /
`self_deploy_enabled`): инвариант страховки (FR-4) держится независимо от флага —
реальные провалы всё равно fail-closed; флаг существует, чтобы мгновенно вернуть
legacy-строгость без передеплоя кода. Флаг живёт в `.env.staging` контейнера
(`ORCH_` prefix), поэтому достижим скриптом внутри `orchestrator-staging`.
`False` → suite строгий → 1:1 поведение до ORCH-061 (AC-7).

### 4. Что НЕ меняется (контракты, AC-8)

- `check_staging_status` / `_parse_staging_status` — **без изменений**: читают
  `staging_status:` (только YAML, `SUCCESS|FAILED`). Толерантность реализована
  ДО артефакта (в exit-code suite → вердикт deployer), внутри существующего пути
  staging-вердикта, не отдельной стадией (TRZ §6).
- **Новый QG-чек НЕ добавляется** → реестр `QG_CHECKS` и снапшот-тест
  (`tests/test_qg_registry_snapshot.py`) неизменны (AC-8 / TC-11).
- `STAGE_TRANSITIONS`, `get_previous_stage`, exit-code хука деплоя (0/1/2),
  `map_exit_code_to_status`, `check_deploy_status`, БАГ-8 — без изменений.
- Условность self-hosting (AC-6): `staging_check.py` канонически бежит только для
  `orchestrator`; `check_staging_status` для не-self репо остаётся
  `(True, "Staging gate N/A …")`. Поведение прочих репо байт-в-байт неизменно.

### 5. Инвариант «no-changes на action-стадии» (Причина №2, FR-3/AC-4)

`launcher._monitor_agent` **уже** не откатывает на «no changes to commit» (просто
логирует и идёт в `_try_advance_stage`; продвижение определяется гейтом). ORCH-061:
- **Фиксируем инвариант** как покрытый тестами контракт: на `deploy-staging`/`deploy`
  для self-deploy продвижение определяется exit0 + гейт-вердиктом, НИКОГДА наличием
  коммита (TC-06).
- **Наблюдаемость (FR-7/AC-11):** в ветке «no changes» логировать явную строку,
  отличающую action-стадию (ожидаемо: артефакт-вердикт, не обязательно код) от
  code-стадии. Резолв стадии задачи по `(repo, branch)`; при
  `stage ∈ {deploy-staging, deploy}` и `self_deploy.self_deploy_applies(repo)` →
  `staging/deploy: no code changes (expected on action stage)`.
- **Regression-guard (TC-07):** на `development` (code-стадия) поведение «no changes»
  неизменно — изменение FR-3 не протекает на не-action стадию.

Изменение минимальное (self-hosting safety, AC-12): не трогает прод-контейнер 8500,
сборки/recreate — только staging (8501).

## Затронутые файлы (для developer)

| Файл | Изменение |
|------|-----------|
| `src/staging_verdict.py` | **новый** leaf-модуль: `classify_check`, `compute_staging_verdict`, `StagingVerdict` (pure, never-raise). |
| `scripts/staging_check.py` | категории в `Results`, вердикт через `staging_verdict`, INFRA-WAIVED-лог, `--strict`. |
| `src/config.py` | флаг `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`). |
| `src/agents/launcher.py` | observability-лог action-stage no-changes (без смены логики продвижения). |
| `.openclaw/agents/deployer.md` | уточнение: exit0 может включать «infra-waived»; контракт `staging_status:` SUCCESS\|FAILED неизменен. |
| `docs/operations/STAGING_CHECK.md` | поведение C9a/C9b, флаг, INFRA-WAIVED, `--strict`. |
| `docs/architecture/README.md` | пометка ORCH-061 в разделе staging-гейта (уже внесена архитектором). |
| `CHANGELOG.md` | запись ORCH-061. |
| `tests/` | TC-01…TC-14 (см. `04-test-plan.yaml`). |

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

**Плюсы**
- Петля устранена структурно: ложный инфра-FAIL → SUCCESS (waived) → нет отката (G1/G2).
- Страховка цела: любая реальная pipeline-проверка fail-closed → FAILED → откат (G4/FR-4).
- Чистая вердикт-логика юнит-тестируема без live staging/docker (NFR-тестируемость).
- Контракты гейтов/стадий/вердиктов/реестра не тронуты (AC-8); схема БД не меняется (AC-9).
- Мгновенный откат к legacy через kill-switch (AC-7).
- Разблокирует автономный self-deploy (ORCH-54).

**Минусы / ограничения**
- C9a/C9b теперь могут заваиверить **реальный** даунстрим-регресс именно в создании
  ветки / постановке analyst-job (узкий риск). Митигировано: waiver только когда C7/C8
  и все прочие REAL зелёные; allowlist жёстко = {C9a, C9b}; INFRA-WAIVED логируется и
  виден оператору. См. `10-tech-risks.md` (R-1).
- Толерантность скрывает «нездоровье sandbox» как зелёное-с-допущением; отличимо
  только по INFRA-WAIVED-логу/комментарию (наблюдаемость обязательна, FR-7).
- Honest 10/10 в sandbox (направление а) остаётся желательным hardening, но не блокером.

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

- **Только (а) — починить sandbox-инфру:** хрупко, не структурно, вне автономной
  досягаемости таска. Оставлено как опциональное hardening.
- **«Зелёный по умолчанию» при недоступности проверок:** запрещён FR-4 (fail-closed).
- **Новый QG-чек `check_staging_infra_tolerant`:** избыточно — менял бы реестр
  `QG_CHECKS` и снапшот; толерантность лучше живёт в suite/вердикте до артефакта.
- **Толерантность внутри `check_staging_status` через структурный артефакт:**
  потребовал бы сменить контракт `15-staging-log.md` и научить deployer писать
  per-check категории — больше движущихся частей; отклонено в пользу решения в suite.
