# ADR-001 (ORCH-058): Провенанс staging-образа перед BUILD-ONCE retag в прод

## Статус
Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`.
Метка: `arch:major-change` (новая deploy-safety модель + новый QG + новый режим хука).

## Контекст

ORCH-36 сделал стадию `deploy` исполняемой для self-hosting (`orchestrator`): Phase B
(`self_deploy.build_deploy_command`) запускает детачед host-хук, который шагом **2b**
(BUILD-ONCE) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без `docker build`** —
«прод получает ровно тот артефакт, что прошёл staging».

Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и собран из провалидированного кода**.
На практике этой гарантии НЕТ (BRD §2):

- Стадия `deploy-staging` запускает только `scripts/staging_check.py` против **уже
  работающего** контейнера 8501 — что бы в нём ни крутилось. Пересборка staging-образа —
  ручная операция (STAGING.md / ORCH-34), вне конвейера.
- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом.

Инцидент (LESSONS_ORCH-036 п.4 — **самый опасный** из 4 багов bootstrap): staging-образ
не пересобрали из нового `main` → `staging_check` прошёл против СТАРОГО кода → BUILD-ONCE
retag промоутнул СТАРЫЙ образ в прод. Деплой «зелёный» (`result=0`, health ok), но прод
**молча откатился** на код 2-дневной давности. Орк обслуживает все проекты из одного
прод-инстанса → тихий регресс инструмента = групповой инцидент.

Текущая защита (staging-гейт, merge-gate, health-check хука) этот класс НЕ ловит: все
гейты зелёные, потому что проверяют **не тот артефакт**, что уезжает в прод.

## Инвариант, который нужно обеспечить

`INV-FRESH` (ТЗ §1): образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в
прод, собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если
это недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`,
БАГ-8), прод не трогается.

### Якорь «провалидированного коммита»

**SHA = `git rev-parse HEAD` в worktree ветки задачи ПОСЛЕ merge-gate** (т.е. после
возможного `auto_rebase_onto_main` + `push --force-with-lease`). Это ровно тот tree,
который merge-gate ре-тестировал зелёным и который сольётся в `main`. Один helper
`validated_revision(repo, branch)` (never-raise) вычисляет SHA и служит ЕДИНСТВЕННЫМ
источником и для штампа сборки (Стратегия A), и для ожидаемого ревижна (Стратегия B) —
два потребителя одного якоря не могут разойтись.

## Решение: A + B (defense in depth)

Ни одна стратегия по отдельности не закрывает задачу:

- **B в одиночку** (fail-fast по провенансу) делает тихий промоут структурно невозможным,
  НО если staging-образ устарел — fail-fast'ит **навсегда** (нет пути к зелёному без
  ручной пересборки) → нарушает BR-5 / AC-6 (deadlock), воспроизводит ровно тот
  bootstrap-разрыв, который мы устраняем.
- **A в одиночку** (пересборка из провалидированного коммита) закрывает петлю «валидируем =
  промоутим», НО не имеет утверждения В МОМЕНТ retag: гонка/отключение/сбой пересборки
  снова даст тихий промоут.

Поэтому берём **обе**, как взаимодополняющие слои:

### Стратегия A — пересборка staging-образа из провалидированного коммита (liveness, AC-4/AC-6)

Для self-hosting на ребре `deploy-staging → deploy`, **после merge-gate** (когда
валидированный HEAD финализирован) и **до Phase A**, детерминированный код:

1. Вычисляет `sha = validated_revision(repo, branch)`.
2. Пересобирает `orchestrator-orchestrator-staging` из **worktree ветки** (build-context =
   валидированный tree) с `--build-arg GIT_SHA=<sha>` и пересоздаёт контейнер 8501 на
   свежем образе (`--no-build`).
3. Прогоняет `staging_check.py --mode stub` против свежего 8501.

Результат: ровно ЭТОТ образ (с лейблом `revision=<sha>`) становится `SOURCE_IMAGE` для
прод-retag → петля замкнута, валидируем и промоутим один артефакт (AC-4). Пересборка/
recreate трогают **ТОЛЬКО staging (8501)**, НИКОГДА прод (8500) (AC-9).

Исполнение — через host (ssh, синхронно): docker CLI / compose доступны на ХОСТЕ, не в
контейнере (Dockerfile ставит только `openssh-client git`; staging_check уже гоняется
`docker exec`-ом на хосте). Новый режим хука `--build-staging` (см. ниже) выполняет сборку
и recreate. Синхронный ssh достаточен — рестарт staging не убивает прод-worker (в отличие
от Phase B, где нужен detached + finalizer).

Реализуется как **детерминированный QG-под-чек `check_staging_image_fresh`** (по образцу
`check_branch_mergeable`, ORCH-043): pure-условность + never-raise; для прочих репо →
`(True, "N/A")`. Регистрируется в `QG_CHECKS` и в `tests/test_qg_registry_snapshot.py`.
Вызов — на ребре через `_handle_image_freshness(...)` в `stage_engine` (рядом с
`_handle_merge_gate`, ПОСЛЕ него, ДО Phase A). FAIL → откат на `development` + release
merge-lease (как merge-gate). **`STAGE_TRANSITIONS` (набор стадий) НЕ меняется** — это
под-гейт ребра.

### Стратегия B — fail-closed провенанс-guard в хуке (safety, AC-1/AC-2/AC-3)

1. **`Dockerfile`**: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`.
   Без build-arg лейбл пустой → fail-closed на стороне B (см. ниже).
2. **`build_deploy_command`**: вычисляет `EXPECTED_REVISION = validated_revision(repo,
   branch)` и пробрасывает в env команды хука.
3. **`orchestrator-deploy-hook.sh` шаг 2b** — ПЕРЕД `docker tag`:
   - читает лейбл `SOURCE_IMAGE`:
     `docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$SOURCE_IMAGE"`;
   - сравнивает с `$EXPECTED_REVISION`;
   - несовпадение / пустой лейбл / пустой `EXPECTED_REVISION` / ошибка inspect →
     `log` + `exit 1` (**fail-closed**, никогда не промоутить «на авось»).
   - **Обратная совместимость:** при НЕзаданном `EXPECTED_REVISION` — текущее поведение
     (проверка пропускается), чтобы не сломать не-self репо и legacy-вызовы.
4. `exit 1` уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется), Phase C
   пишет `deploy_status: FAILED` → откат на `development` (БАГ-8). Прод не рестартуется на
   устаревший образ — guard срабатывает ДО `docker tag`/restart.

### Новый режим хука `--build-staging` (для Стратегии A)

`orchestrator-deploy-hook.sh --build-staging` (env: `GIT_SHA`, `BUILD_CONTEXT` = host-путь
worktree, `TARGET_IMAGE=orchestrator-orchestrator-staging`, `TARGET_SERVICE`,
`COMPOSE_PROFILE=staging`, `TARGET_PORT=8501`):
`docker build --build-arg GIT_SHA=<sha> -t <TARGET_IMAGE> <BUILD_CONTEXT>` →
`docker compose --profile staging up -d --no-build orchestrator-staging` → health 8501.
Тот же exit-code-контракт (0=ok). Дефолты режима — STAGING-safe (как у `--deploy`).

Host-путь build-context выводится из container-пути worktree заменой
`repos_dir → host_repos_dir` (как `host_state_dir` в `self_deploy.py`); требуется
производный helper host-worktree-пути (или новая настройка `ORCH_HOST_WORKTREES_DIR`).

## Конфигурация (`src/config.py`, все с префиксом `ORCH_` — урок ORCH-36 п.2)

- `image_freshness_enabled: bool = True` — **единый** kill-switch ВСЕЙ фичи (A и B вместе).
  `False` → ни пересборки, ни проброса `EXPECTED_REVISION` → поведение ровно как ORCH-36
  (BUILD-ONCE без guard). A и B включаются/выключаются **как одно целое**, чтобы не было
  опасной полу-конфигурации «B без A» (вечный fail-fast).
- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как
  `self_deploy_repos` / `merge_gate_repos`).

> **Инвариант конфигурации (AC-6):** B активен ТОЛЬКО когда активен A. По умолчанию
> (`image_freshness_enabled=True`) валидный деплой всегда доходит до зелёного (A пересобирает
> → лейбл == EXPECTED → B пропускает). Полное выключение → legacy ORCH-36 поведение.

## Порядок на ребре `deploy-staging → deploy` (self-hosting)

1. `check_staging_status` (существующий) — первичный staging-вердикт агента (smoke,
   что staging-инфра жива).
2. merge-gate `check_branch_mergeable` (существующий) — финализирует валидированный HEAD
   (rebase если позади, ре-тест зелёный, lease HELD). DEFER на busy-lock → возврат без
   пересборки.
3. **`check_staging_image_fresh` (НОВЫЙ, Стратегия A)** — пересборка из валидированного
   HEAD + recreate 8501 + `staging_check`. FAIL → откат на `development` + release lease.
4. Phase A (существующий) → запрос approve.
5. Phase B (human Approved) → `build_deploy_command` с `EXPECTED_REVISION` → хук-guard (B)
   → BUILD-ONCE retag только при совпадении → restart прод → Phase C finalizer.

> Двойной прогон `staging_check` (агент на стадии + код на шаге 3) — **намеренный**: первый
> валидирует УЖЕ работающий (потенциально устаревший) 8501 как soft pre-check; авторитетный
> — шаг 3 против СВЕЖЕГО образа, который и уедет в прод. `--mode stub` быстр и без LLM-трат.

## Контракты, которые НЕ меняются (AC-7)

`STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status` / `_parse_deploy_status` (frontmatter-only),
БАГ-8 rollback, terminal-sync `deploy → done`, merge-gate (ORCH-43), Phase A/B/C ORCH-36.
**Схема БД — без миграций** (состояние свежести не персистится в БД; провенанс живёт в
лейбле образа). Добавление `check_staging_image_fresh` в `QG_CHECKS` — ожидаемое расширение
реестра (ТЗ §10), не входит в замороженный список AC-7.

## Last-line-of-defence / fail-closed (AC-2/AC-3)

Даже если A отключена/проиграла гонку/сбойнула — **B (хук-guard) делает тихий промоут
устаревшего образа структурно невозможным**: рассинхрон лейбла и `EXPECTED_REVISION` →
`exit 1` ДО retag → FAILED → откат. На любом сомнении (нет лейбла, пустой ожидаемый SHA,
ошибка inspect) — трактуется как несоответствие. Прод никогда не трогается «на авось».

## never-raise (AC-8)

`validated_revision`, `rebuild_staging_image`, `check_staging_image_fresh`,
`build_deploy_command` (проброс EXPECTED) — все защищены try/except, любая ошибка → безопасный
вердикт (для A-под-чека: `(False, reason)` с release lease; пустой `EXPECTED_REVISION` на
сомнении → B fail-closed). Исключение никогда не всплывает в `stage_engine`.

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

**Плюсы**
- Класс «тихого регресса прод» закрыт структурно (B), а валидный деплой всегда доходит до
  зелёного (A) — bootstrap-разрыв «ручная пересборка staging» устранён.
- Валидируем и промоутим один и тот же артефакт (AC-4); провенанс машиночитаем (лейбл).
- Единый kill-switch, поэтапный раскат, условность только для self-hosting — без регрессий
  для не-self репо.

**Минусы / ограничения**
- Латентность ребра растёт: +`docker build` staging + recreate 8501 + повторный
  `staging_check` перед Phase A. Приемлемо (выполняется в monitor-треде, как merge-gate
  re-test; bounded timeouts).
- `staging_check` гоняется дважды (soft pre-check агента + авторитетный код) — осознанная
  плата за AC-4. Возможная будущая оптимизация: облегчить шаг 3 до health+revision-smoke,
  если merge-gate re-test признать достаточным для кода.
- Требуется host-доступ к `docker build`/`compose` под slin (как для `--deploy`) и writable
  build-context (worktree) — заложено инфра-требованиями (07).
- Новая под-компонента (QG `check_staging_image_fresh` + режим хука `--build-staging`) →
  `arch:major-change`.

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

- **Только B.** Deadlock без авто-пересборки (BR-5/AC-6). ❌
- **Только A.** Нет утверждения в момент retag → гонка/отключение снова даёт тихий промоут
  (AC-2/AC-3). ❌
- **Rebuild в хуке на Phase B (прод-сторона).** Уничтожает BUILD-ONCE (прод-rebuild) и
  промоутит образ, который staging-e2e никогда не валидировал. ❌
- **Rebuild напрямую из контейнера через docker.sock.** В образе нет docker CLI/compose;
  staging-операции и так host-side (ssh). ❌

## Связанные ADR
Глобальный: `docs/architecture/adr/adr-0008-staging-image-provenance.md`.
`adr-0007-executable-self-deploy` (ORCH-36, BUILD-ONCE), `adr-0006-merge-gate` (ORCH-43,
образец edge-под-гейта), `adr-0003-staging-gate` (ORCH-35, условность), `adr-0005`
(run-as-host-uid).
