diff --git a/docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md b/docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md new file mode 100644 index 0000000..81e448c --- /dev/null +++ b/docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md @@ -0,0 +1,105 @@ +--- +work_item: ORCH-123 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# adr-0049: Граница исполнения docker — все docker-операции host-side, не изнутри app-контейнера + +> **Сквозной (cross-cutting) ADR.** Кодифицирует инвариант **«docker-операции оркестратора +> исполняются host-side через доверенный ssh-канал, никогда изнутри прод-контейнера»**, охватывающий +> компоненты ORCH-036/058/115/123/101, и **амендит** execution-strategy-решение +> [adr-0048](adr-0048-deterministic-staging-runner.md) (D3/D5). Поводом стала задача ORCH-123 (баг: +> staging-runner отклонился от инварианта). Локальная детализация (D1–D9) — +> `docs/work-items/ORCH-123/06-adr/ADR-001-host-side-staging-execution-and-env-classification.md`. + +## Статус +Proposed + +## Контекст + +Прод-контейнер `orchestrator` (8500) **не содержит docker CLI** (`Dockerfile:11`: +`openssh-client git curl ca-certificates` + pinned gitleaks; `python:3.12-slim` docker не несёт). +`/var/run/docker.sock` смонтирован rw + `group_add 999` (ORCH-040 «МИНА 1»), но **клиента, который +бы им воспользовался, нет** — сознательно: добавление CLI/SDK активировало бы root-эквивалентный путь +исполнения для всего, что бежит в контейнере (вкл. LLM-агентов). Поэтому в оркестраторе сложился +**инвариант исполнения**, ранее не выделенный в отдельный ADR: + +- **ORCH-036** (`self_deploy.build_deploy_command`, [adr-0007](adr-0007-executable-self-deploy.md)) — + прод-деплой исполняется host-side через `ssh + setsid bash --deploy` на `127.0.0.1`. +- **ORCH-058** (`image_freshness`, [adr-0008](adr-0008-staging-image-provenance.md)) — ребилд + staging-образа (`ssh … bash --build-staging`) и инспекция revision + (`image_revision(ssh_target=…)`) — host-side; модуль прямо документирует: + *«docker lives on the HOST (the container ships only openssh-client git)»*. +- **ORCH-101** ([adr-0036](adr-0036-replication-foundation-host-parametrization.md)) — host-параметры + канала (`deploy_ssh_*`, `deploy_host_repo_path`, `repos_dir`/`host_repos_dir`) расхардкожены. + +**ORCH-115** ([adr-0048](adr-0048-deterministic-staging-runner.md)), заменяя LLM-деплойера +детерминированным `staging_runner`, **отклонился** от инварианта: зашил `docker exec` **изнутри** +прод-контейнера через `proc_group → Popen` → `FileNotFoundError: docker` → постоянный +environment-дефект, ложно маршрутизированный как транзиентная инфра → DEFER → fail-closed FAILED → +**откат `deploy-staging → development`** (винит код задачи за дефект окружения раннера). Инцидент +ORCH-116/ORCH-123. + +## Решение + +**Кодифицировать инвариант (нормативно):** docker-операции оркестратора (`docker`/`docker compose`/ +`docker exec`/`docker inspect`/`docker tag`) исполняются **host-side** через доверенный ssh-канал +(`deploy_ssh_host=127.0.0.1`, ключ смонтирован, `openssh-client` в образе) — **никогда** изнутри +прод-контейнера, который docker CLI не несёт. `/var/run/docker.sock` **не используется** изнутри +контейнера; docker CLI/SDK в образ **не добавляется** (любое исключение — отдельный явный +security-review: socket-из-контейнера = root-эквивалент на хосте, обслуживающем все проекты). + +**ORCH-123 приводит `staging_runner` в соответствие** (амендит adr-0048 D3/D5): +- **D3 (амендмент adr-0048):** `staging_runner.build_staging_command` теперь обёртывает + `docker exec orchestrator-staging python3 staging_check.py …` в `ssh @ '<…>'` (зеркало + `image_freshness.image_revision(ssh_target=…)`). Внутренняя команда сюиты и exit-код-контракт — те + же; меняется лишь **инициатор/канал**. +- **D5 (амендмент adr-0048 двухуровневого исхода):** введён **третий** класс исхода `permanent-env` + (зеркало `merge_gate.classify_retest_failure`, ORCH-110); корневой инвариант — **«сюита не + исполнилась» (environment ИЛИ транзиентная инфра) НИКОГДА не оканчивается код-фейл-откатом и не жжёт + developer-retry**; откат — только для реально исполнившейся сюиты с `exit≠0`. Терминал исчерпания + DEFER изменён с fail-closed-FAILED+advance на **infra-HOLD + alert** (как ORCH-110 D3). + +Кросс-каттинговые инварианты (сохранены **байт-в-байт**, как adr-0048): +- `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_staging_status`/`_parse_staging_status` / + machine-verdict-ключи (`staging_status:`/`deploy_status:`/…) / **схема БД** — не тронуты (замена + *стратегии исполнения продюсера*, не гейта/стадии). +- Единственный транспорт LLM-консультации (`launcher._spawn`/S0, [adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md)) + — соблюдён (раннер LLM не зовёт). +- Сквозной бюджет времени ORCH-065/109/110 (`reaper_max_running_s` > Σ(работ на ребре) + grace) — не + растёт (host-side ssh заменяет in-container call, окно ≤ `staging_runner_timeout_s`). +- Граница transition-lease ORCH-114 — берётся внутри `advance_stage`; раннер не трогает. + +Скоуп — **self-hosting only** (`staging_runner_repos=""` → `is_self_hosting_repo`); под флагами +`staging_runner_enabled` (→ LLM-путь) и **новым** `staging_runner_exec_host_side` (дефолт `True` → +фикс; `False` → прежний in-container call). never-raise во всех публичных функциях. + +## Последствия + +- **+** Инвариант «docker host-side» выделен и задокументирован → будущие компоненты не повторят + отклонение ORCH-115; reviewer ловит in-container docker как регресс инварианта. +- **+** staging-сюита реально исполняется в проде; инфра/environment ≠ код-фейл на staging-ребре + (закрыт RCA-класс ORCH-110 на этом ребре полностью); анти-over-tolerance цел. +- **+** Без расширения привилегий (нет docker CLI/SDK в контейнере, сокет не используется); согласовано + с ORCH-036/058. +- **−** Remote tree-kill ограничен локальным ssh-клиентом (как `image_freshness.rebuild_staging_image`); + backstop — bounded таймаут внутри `staging_check.py`. +- **−** Permanent-env/исчерпавшая-DEFER задача держится на `deploy-staging` (блокирует serial-gate репо + до починки оператором) — принятый tradeoff (зеркало ORCH-110), self-hosting only. +- **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` (→ LLM) или `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE=false` + (→ in-container call). + +## Ссылки +- Локальный ADR: `docs/work-items/ORCH-123/06-adr/ADR-001-host-side-staging-execution-and-env-classification.md` +- Амендит: [adr-0048](adr-0048-deterministic-staging-runner.md) (D3/D5 ORCH-115) +- Опирается на: [adr-0007](adr-0007-executable-self-deploy.md) (ORCH-036 self-deploy ssh), + [adr-0008](adr-0008-staging-image-provenance.md) (ORCH-058 image-freshness host-side docker), + [adr-0042](adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md) (ORCH-110 proc_group + + classify + infra-tolerance), [adr-0036](adr-0036-replication-foundation-host-parametrization.md) + (ORCH-101 host-параметризация) +- Сверено по коду: `src/staging_runner.py`, `src/self_deploy.py:220`, `src/image_freshness.py:185/246`, + `scripts/orchestrator-deploy-hook.sh:166/197`, `Dockerfile:11`, `docker-compose.yml` diff --git a/docs/work-items/ORCH-123/06-adr/ADR-001-host-side-staging-execution-and-env-classification.md b/docs/work-items/ORCH-123/06-adr/ADR-001-host-side-staging-execution-and-env-classification.md new file mode 100644 index 0000000..c91f278 --- /dev/null +++ b/docs/work-items/ORCH-123/06-adr/ADR-001-host-side-staging-execution-and-env-classification.md @@ -0,0 +1,333 @@ +--- +work_item: ORCH-123 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# ADR-001: Стратегия исполнения staging-раннера — host-side ssh + классификация environment-дефекта + +Work Item: **ORCH-123** — staging-runner (ORCH-115) исполняет `docker exec` **изнутри** +прод-контейнера, где нет docker CLI → постоянный environment-дефект ложно маршрутизируется как +код-фейл (откат `deploy-staging → development`). Стадия: **architecture** (задача-`Bug`, +эскалирована `escalate: full-cycle` — `01-brd.md`, D5 ORCH-019). +Сквозная регистрация: **`docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md`** +(решение кросс-каттинговое — кодифицирует инвариант «docker-операции исполняются host-side», +охватывающий ORCH-036/058/115/123/101, и амендит execution-strategy-решение adr-0048). + +## Статус +Proposed + +## Контекст + +**Установленный факт (сверено по коду и инфраструктуре, BRD §1):** ORCH-115 +(`src/staging_runner.py:154`, `build_staging_command`) исполняет staging-сюиту командой +`["docker", "exec", "orchestrator-staging", "python3", ".../staging_check.py", …]` **изнутри +прод-контейнера `orchestrator`** через `proc_group.run_in_process_group` → `subprocess.Popen`. +Прод-контейнер docker CLI **не содержит**: + +- `Dockerfile:11` ставит `openssh-client git curl ca-certificates` (+ pinned gitleaks) — бинаря + `docker` нет; `python:3.12-slim` его тоже не несёт → `Popen(["docker", …])` падает + `FileNotFoundError: [Errno 2] No such file or directory: docker`. +- `docker-compose.yml:40` монтирует `/var/run/docker.sock` (rw) в сервис `orchestrator` и + добавляет `group_add 999` (ORCH-040 «МИНА 1»). **Сокет в контейнере есть, а CLI-клиента, + который бы им воспользовался, — нет.** Проблема не в доступе к Docker, а в **отсутствии + исполняемого клиента** в образе. + +Цепочка отказа в проде (job ORCH-116): `Popen` → `OSError` → `proc_group` деградирует в +`ProcResult(returncode=None, timed_out=False)` → в `run_staging_gate` (`:528`) +`suite_ran = (returncode is not None) and (not timed_out)` = **False** → ветка tool-error → +`_handle_tool_error` (`:415`) → инфра-DEFER ×2 → бюджет исчерпан → +`write_staging_log(..., "FAILED", tool_error=True)` + `_advance` → **существующий откат +`deploy-staging → development`** (как код-фейл задачи). Так до фикса **любая** self-hosting задача, +доходящая до `deploy-staging`, обречена на ложный откат. + +**Корневая классификационная ошибка (BRD §1):** двухуровневый исход ORCH-115 (D5) различает только +«сюита исполнилась» vs «сюита не исполнилась (транзиентная инфра → DEFER → fail-closed FAILED)». +Отсутствие docker CLI — **детерминированный, постоянный environment-дефект**, а не транзиентная +икота: ретраи бессмысленны (каждая попытка падает идентично), а терминальный +fail-closed-FAILED-откат винит код/разработчика за дефект окружения раннера. + +**Установленный паттерн, от которого ORCH-115 отклонился (сверено по коду):** прод-деплой ORCH-036 +(`src/self_deploy.py:220`, `build_deploy_command`) и image-freshness ORCH-058 +(`src/image_freshness.py`) **уже** исполняют все docker-операции **host-side** через +`ssh + setsid`/`ssh` на `deploy_ssh_host=127.0.0.1` (ssh-ключ смонтирован, `openssh-client` в +образе). `image_freshness.image_revision` (`:185`) прямо документирует инвариант: +> *«`docker` lives on the HOST (the container ships only `openssh-client git`)»* +и при `ssh_target` обёртывает `docker image inspect` в ssh. Хост-хук +`scripts/orchestrator-deploy-hook.sh --build-staging` (`:197`) уже делает host-side +`docker exec "$STAGING_CONTAINER" python3 staging_check.py` — на хосте, где docker CLI есть. + +**Вывод:** ORCH-115 при замене LLM-деплойера детерминированным раннером **отклонился** от уже +установленного инварианта «docker-операции — host-side, не изнутри app-контейнера». Дефект — в +**стратегии исполнения** раннера, **не** в гейте `check_staging_status` и **не** в контракте +`15-staging-log.md` (BRD §«Вне объёма»; NFR-1). + +> 🔎 **Проверка точки BRD (как сюита исполнялась до ORCH-115 LLM-деплойером):** до ORCH-115 +> staging-сюиту исполнял LLM-агент `deployer` (`.openclaw/agents/deployer.md` шаг 1), которому +> формулировка шага позволяла исполнить шаги на хосте/в нужном контексте; ORCH-115 жёстко зашил +> `docker exec` именно **изнутри** прод-контейнера через `proc_group`. То есть ORCH-115 сделал +> ранее-гибкий путь жёстко-неработоспособным в проде. На выбор стратегии фикса это не влияет +> (host-side восстанавливает рабочий инвариант независимо от истории). + +## Решение + +Восстановить **host-side стратегию исполнения** staging-сюиты через уже доверенный ssh-канал +(ORCH-036/058) и ввести **трёхстороннюю классификацию исхода**, чтобы постоянный +environment-дефект и транзиентная инфра **никогда** не оканчивались код-фейл-откатом, а только +реально исполнившаяся упавшая сюита — откатывала (анти-over-tolerance). Гейт / контракт артефакта / +`STAGE_TRANSITIONS` / схема БД — **байт-в-байт неизменны** (замена *стратегии исполнения продюсера*, +не гейта). Аддитивно, под флагами, never-raise, скоуп self-hosting. + +### D1 — Стратегия исполнения: host-side ssh, обёртывающий `docker exec` (BR-1 / FR-1 / AC-1, AC-2) + +`staging_runner.build_staging_command()` теперь строит **ssh-обёрнутую** команду (зеркало +`self_deploy.build_deploy_command` / `image_freshness.image_revision(ssh_target=…)`): + +``` +ssh -o StrictHostKeyChecking=no @ \ + 'docker exec python3 //scripts/staging_check.py \ + --base-url http://localhost: --mode stub' +``` + +- Внутренняя команда (`docker exec … staging_check.py … --mode stub`) — **та же**, что прежде + (контракт сюиты и exit-кода не меняется, BRD §«Вне объёма»); собирается из config (ORCH-101, без + host-хардкодов), `shlex.quote`-ится в inner-строку ssh. +- Канал — существующий: `deploy_ssh_user`/`deploy_ssh_host` (compose: + `ORCH_DEPLOY_SSH_HOST=127.0.0.1`, `ORCH_DEPLOY_SSH_USER=slin`); ssh-ключ смонтирован + (`${ORCH_HOST_SSH_DIR}:…/.ssh:ro`), `openssh-client` в образе (`Dockerfile:11`). **Новых + секретов/привилегий не вводится** (NFR-3). +- Запуск по-прежнему через `proc_group.run_in_process_group` (bounded local timeout + tree-kill + локального ssh-клиента, ORCH-110). **Ограничение (документируется, D-Последствия):** tree-kill + убивает локальный ssh-клиент, но не гарантирует убийство удалённого `docker exec`-дерева на + хосте; backstop — bounded таймаут самой `staging_check.py` внутри сюиты. Это **то же** допущение, + что у уже принятого `image_freshness.rebuild_staging_image` (синхронный ssh без remote tree-kill). +- **Self-hosting safety (NFR-2 / AC-8):** argv содержит **только** `docker exec + python3 staging_check.py … --mode stub` — **никаких** литералов рестарта прод-8500, + `docker compose up orchestrator`/`--build` прода, force-push, записи в `main`, правки `.env`. + Структурный анти-литерал-тест (ORCH-115 TC-12, расширяется TC-08 ORCH-123) держит инвариант на + ssh-обёрнутой команде. + +### D2 — Почему host-side ssh, а НЕ socket/CLI-в-контейнере — security-review (NFR-3 / AC-9) — **ключевое решение безопасности** + +BRD/TRZ перечисляют четыре кандидата (host-side ssh / Docker SDK поверх `docker.sock` / docker CLI +в образе / выделенный hook-режим). Любой вариант с **прямым использованием `docker.sock`/CLI +изнутри контейнера** = **root-эквивалент на хосте** и требует явного security-обоснования. + +**Разбор поверхности атаки:** +- `/var/run/docker.sock` смонтирован rw + `group_add 999` **уже** (ORCH-040, «МИНА 1»). Это + **дремлющая** возможность: контейнер может *достучаться* до сокета, но **не имеет клиента**, чтобы + им воспользоваться. Добавление docker CLI в образ **или** Docker SDK (`pip install docker`) + превратило бы дремлющую возможность в **активный root-эквивалентный путь исполнения**, доступный + не только worker-коду, но и **любому subprocess'у в том же контейнере — включая LLM-агентов**, + которых оркестратор спавнит (`launcher._spawn`). Это **расширение поверхности атаки** (компрометация + агента/зависимости → полный контроль docker → root на хосте, обслуживающем ВСЕ проекты). +- Host-side ssh держит docker-операции **на хосте** под **существующим** доверенным key-gated + каналом (ORCH-036/058), **не наделяя** in-container код docker-возможностями. Это **строго + безопаснее** и согласуется с задокументированным инвариантом `image_freshness`. + +**Решение:** выбран **host-side ssh** (D1). Docker CLI/SDK в контейнер **не добавляется**; +использование `docker.sock` **не расширяется**. ORCH-123 поверхность ORCH-040 (смонтированный сокет) +**не трогает и не расширяет** (правка compose вне объёма — BRD §«Вне объёма», happy-path +прод-деплоя/ORCH-040 не трогаем). Если будущая задача потребует socket/CLI-в-контейнере — это +**отдельный** явный security-review; этот ADR его не выдаёт (AC-9 удовлетворён выбором ssh-варианта, +переиспользующего доверенный ORCH-036 механизм без расширения привилегий). + +### D3 — Трёхсторонняя классификация исхода (FR-2 / FR-3 / AC-3, AC-4, AC-6) — **ключевое решение** + +Чистая функция `classify_staging_outcome(result, ssh_configured) -> str` (зеркало +`merge_gate.classify_retest_failure`, ORCH-110 D2; pure, never-raise) различает **три** класса: + +| Класс | Детерминированный сигнал | Семантика | +|-------|--------------------------|-----------| +| `permanent-env` | `returncode is None and not timed_out` (spawn-error локального бинаря) **ИЛИ** ssh-target не сконфигурирован **ИЛИ** `returncode in (126, 127)` **ИЛИ** stderr содержит маркеры `command not found` / `executable file not found` / `No such container` / `is not running` / `Cannot connect to the Docker daemon` / `Is the docker daemon running` | Постоянный дефект окружения (нет docker/ssh, контейнер не поднят, демон недоступен). Ретрай бессмыслен. | +| `transient-infra` | `timed_out` **ИЛИ** `returncode == 255` (ssh transport/connection) | Транзиентная инфра (сеть/handshake/стенд на миг недоступен). Ретрай осмыслен. | +| `suite-ran` | распознанный исполнительный exit-код (любой иной int) **и НЕТ** env-маркера в stderr | Сюита **реально исполнилась** — доверяем коду (`0→SUCCESS`, `≠0→FAILED`). | + +- **Дизамбигуация «сюита exit=1 vs `docker exec` No such container exit=1»** — по **скану stderr на + env-маркеры** (а не по голому exit-коду): env-маркер → `permanent-env`; нет маркера → `suite-ran` + (`staging_check.py` пишет PASS/FAIL в stdout, её коды — 0/≠0 без 126/127/255). Маркер-сет + финализирует разработчик; набор выше — стартовый, по образцу scope-guard `merge_gate`. +- **Анти-over-tolerance (BR-3 / AC-4, зеркало ORCH-110 BR-6):** распознанный `suite-ran` exit-код + **никогда** не реклассифицируется в инфру → реальный фейл сюиты (`exit≠0`) идёт прежним путём. +- **Fail-safe при неоднозначности:** неизвестный сигнал по умолчанию → `transient-infra` (DEFER), а + **не** `suite-ran` — mis-route environment→код-фейл есть именно тот дефект, что чиним; но + распознанный suite-exit без env-маркера всегда `suite-ran` (BR-3 не ослаблен). + +### D4 — Маршрутизация исходов (BR-2 / BR-3 / FR-2 / FR-3 / AC-3, AC-4) — **инвариант: инфра ≠ код-фейл** + +| Класс | Действие | +|-------|----------| +| `suite-ran` | **Без изменений** (ORCH-115): `write_staging_log` + `_advance` → `SUCCESS`→под-гейты+Phase A; `FAILED`→**прежний** откат `deploy-staging → development` + developer-retry (`stage_engine.py:932`). BR-3 байт-в-байт. | +| `permanent-env` | **Немедленный отличимый infra-исход:** DEFER-цикл **пропускается** (ретрай постоянного дефекта бессмыслен — FR-3); записывается структурный `permanent-env`-лог + операторский алерт («инфра/окружение, **НЕ дефект кода**», кликабельный номер); **НЕ** `_advance`, **НЕ** откат, **НЕ** developer-retry. Задача **остаётся на `deploy-staging`** (infra-HOLD). Счётчик `permanent_env`++. | +| `transient-infra` | **Существующий** bounded DEFER (re-queue `deployer`-джоба + delay + restart-safe маркер `_INFRA_RETRY_MARKER`, cap `staging_runner_infra_max_retries`). | + +**Изменение терминала исчерпания DEFER (супершид ORCH-115 ADR D5):** при исчерпании бюджета DEFER +исход — **infra-HOLD + алерт** (как `permanent-env`), а **НЕ** прежний fail-closed +`write_staging_log("FAILED") + _advance` (= откат). Прежний терминал нарушал BR-2 (транзиентная инфра, +которая не прояснилась, всё равно ложно откатывалась как код-фейл). Новый терминал выравнивает +staging-раннер с моделью `merge_gate` infra-tolerance (ORCH-110 D3: исчерпание инфра → алерт «НЕ +дефект кода», задача **не** уходит в `development`). + +**Корневой инвариант (нормативно):** «сюита **не исполнилась**» (environment ИЛИ транзиентная инфра) +**никогда** не оканчивается код-фейл-откатом `deploy-staging → development` и **никогда** не жжёт +developer-retry. Откат — **только** для реально исполнившейся сюиты с `exit≠0` (`suite-ran`/`FAILED`). +Это закрывает RCA-класс ORCH-110/111…117 на staging-ребре. + +**Re-drive / отсутствие вечного залипания (NFR-5 / AC-11):** infra-HOLD оставляет задачу на +`deploy-staging` в восстановимом состоянии (worker не падает, тугого ретрай-цикла нет, исход виден в +алерте + `GET /queue` + структурном логе). После починки окружения оператором задача переоткатывается +**существующим** механизмом (оператор / reconciler ORCH-053), без нового движка. **Граница (риск +R-2):** reconciler/reaper **должен** трактовать infra-held `deploy-staging`-задачу как удержанную +(пере-выдать `deployer`-джоб), **не** откатывать её в `development` — иначе реинтродукция дефекта; +developer верифицирует (`10-tech-risks.md`). + +### D5 — Prod-like preflight (BR-4 / FR-4 / AC-5) + +`staging_runner.preflight() -> tuple[bool, str]` (bounded, never-raise, self-hosting-скоуп — +`applies(repo)` первым): +- Пробит **канал исполнения** host-side коротким bounded ssh-зондом: + `ssh 'command -v docker >/dev/null && docker inspect -f "{{.State.Running}}" + '` → распознаёт «нет docker на хосте» / «staging-контейнер не поднят» / «ssh + недоступен» **до** того, как реальная задача дойдёт до ложного исхода. +- Вызывается на **старте сервиса** в `main.lifespan` (best-effort, после + `requeue_running_jobs`/`recover_on_startup`, **никогда не блокирует старт**) → условие инцидента + всплывает на рестарте/деплое самого орка, а не постфактум на реальной задаче. Эмитит структурный + лог + Telegram-алерт при неудаче + поле в `snapshot()`. +- **Чисто наблюдательный:** preflight **не** гейтит/блокирует конвейер, **не** трогает стадии/QG, + never-raise (FR-4). Регресс-тест TC-01 моделирует «нет docker CLI в контейнере» (монки-патч + argv/spawn) — **красный до фикса** (in-container `docker exec` → spawn-error → tool-error → ложный + откат), **зелёный после** (host-side стратегия + `permanent-env`-классификация → нет код-фейл-отката). + +### D6 — Условность / kill-switch / обратимость (FR-5 / NFR-4 / NFR-6 / AC-10) + +`config.py` (паттерн `staging_runner_*` / ORCH-101 «дефолт = боевое»): +- **Новый:** `staging_runner_exec_host_side: bool = True` (env `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE`). + `True` (дефолт) → host-side ssh-стратегия (D1, фикс). `False` → прежний in-container `docker exec` + (валиден **только** там, где docker CLI запечён в образ; в текущем проде — нет). Добавить в + `.env.example`. +- **Переиспользуются:** `staging_runner_enabled` (kill-switch; `False` → прежний LLM-`deployer` через + `_spawn` **байт-в-байт**, ORCH-115 fail-safe), `staging_runner_repos` (CSV; пусто → self-hosting + only через `is_self_hosting_repo`), `staging_runner_timeout_s`, `staging_runner_infra_max_retries`/ + `_retry_delay_s` (DEFER-бюджет транзиентной инфры), `deploy_ssh_user`/`deploy_ssh_host`/ + `deploy_host_repo_path` (ssh-канал). +- `applies(repo)` (локально, без сети) — **первым** в `should_intercept`: выключенный флаг = нулевой + оверхед, на `deploy-staging` штатно `_spawn`. **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` + (→ LLM-путь) **или** `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE=false` (→ прежний in-container call). + Для прочих репо `applies==False` → no-op (нулевая регрессия enduro). NFR-6. + +### D7 — Сохранность контракта (NFR-1 / AC-7) — границы O1 + +**Байт-в-байт неизменны:** `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена `QG_CHECKS`/ +`check_staging_status`/`_parse_staging_status`, machine-verdict-ключи (`staging_status:`/ +`deploy_status:`/…), **схема БД** (restart-safe счётчик DEFER — маркер в `task_content`, без +миграции). `15-staging-log.md` сохраняет `staging_status:`-frontmatter (UPPERCASE) + 52c-схему +(`author_agent: staging-runner` / `model_used: n/a`); вердикт читается ТОЛЬКО из frontmatter через +`src/frontmatter.py`. Это замена *стратегии исполнения продюсера*, не гейта/стадии (зеркало +инварианта ORCH-115 NFR-1). transition-lease ORCH-114 берётся **внутри** `advance_stage` — раннер +его не трогает (граница O1, как ORCH-115 D7). + +### D8 — Наблюдаемость (BR-6 / AC-12-набл.) + +`_STAGING_RUNNER_COUNTERS` расширяется ключом `permanent_env` (+ существующие +`runs`/`success`/`failed`/`tool_error`/`deferred`). `snapshot()` / read-only блок `staging_runner` в +`GET /queue` различает **три** не-успешных класса: `code-fail` (исполнившаяся сюита `FAILED`) vs +`transient-infra` (`deferred`) vs `permanent-env` (новый). Один структурный лог-вердикт на прогон +(`work_item`/`repo`/`exit_code`/`status`/`duration_s`/`outcome` ∈ {`code-pass`,`code-fail`, +`transient-infra`,`permanent-env`}) + Telegram-алерт на `permanent-env`/infra-исчерпание с +кликабельным номером и явной формулировкой «инфра/окружение, НЕ дефект кода». + +### D9 — Норматив сопровождения docs (BR-5 / FR-6 / AC-12) — спека для development + +Документация границы исполнения и витрина **коуплены к состоянию кода** (описывают post-fix +поведение; структурный анти-дрейф TC-13 проверяет наличие разделов). По прецеденту **ORCH-115 D11 / +adr-0048** (и маршрутизации analyst'а — `02-trz.md` §2) эти правки применяет **developer атомарно с +кодом** в том же PR, **не** architecture-стадия (иначе доки/тесты описывают не-существующий ещё код). +Точная спека (developer применяет дословно): + +- **`docs/operations/INFRA.md`** — в раздел `## ⚠️ Self-hosting` (или новый под-раздел «Граница + исполнения docker-операций») зафиксировать: контейнер `orchestrator` несёт **только** + `openssh-client git curl` (docker CLI **нет**); `/var/run/docker.sock` смонтирован, но **не + используется изнутри** (нет клиента, сознательно — см. D2); **все** docker-операции + (прод-деплой ORCH-036, image-freshness ORCH-058, **staging-runner ORCH-123**) исполняются + **host-side** через ssh на `127.0.0.1` под смонтированным ключом; staging-сюита (`staging_check.py`) + по-прежнему исполняется **внутри** `orchestrator-staging` (8501) — меняется лишь **кто инициирует** + `docker exec` (host vs прод-контейнер). +- **`docs/architecture/README.md`** — в секции про staging-гейт/ORCH-115 (или соседней) описать + host-side стратегию исполнения staging-раннера и трёхстороннюю классификацию (env ≠ код-фейл), + сослаться на `adr-0049`. +- **`CLAUDE.md`** — раздел ORCH-115 дополнить фиксом ORCH-123 (host-side exec + классификация + environment-дефекта); **`CHANGELOG.md`** — запись `fix:`; **`docs/overview/`** — если затронут + машинно-проверяемый факт (`tests/test_system_docs.py`). +- **Анти-дрейф ORCH-115 (TC-14):** существующие `tests/test_orch115_staging_runner.py` и + LLM-determinization-тесты остаются зелёными (фикс не ломает инварианты ORCH-115: контракт/гейт/ + один транспорт LLM `_spawn`/«ровно один first_slice» целы). + +## Альтернативы + +- **Docker CLI в образ (`Dockerfile`) + `docker exec` через смонтированный сокет** — отвергнуто + (D2): активирует дремлющий root-эквивалентный путь для всего, что исполняется в контейнере (вкл. + LLM-агентов) — расширение поверхности атаки. Требовало бы отдельного security-review; host-side ssh + достигает цели без расширения привилегий. +- **Docker SDK (`pip install docker`) поверх `docker.sock`** — отвергнуто: та же security-проблема, + что выше, + новая зависимость + не переиспользует доказанный канал. +- **Выделенный режим хука (`--run-staging-check`) через ssh** — отвергнуто как избыточное: прецедент + `image_freshness.image_revision(ssh_target=…)` уже исполняет docker-команду через **прямой** ssh + (без хука); `--build-staging` хука перегружен ребилдом образа (это работа ORCH-058, не раннера) — + переиспользовать его нельзя, а заводить новый hook-режим тяжелее прямого ssh-wrap. Прямой + ssh-обёртки (D1) достаточно. +- **tool-error → немедленный `FAILED`-откат на `development`** (исходный путь до ORCH-115 D5) — + отвергнуто: это в точности дефект ORCH-123 (и анти-паттерн ORCH-110). +- **Сохранить ORCH-115 D5 терминал (исчерпание DEFER → fail-closed `FAILED` + `_advance`)** — + отвергнуто: нарушает BR-2 (транзиентная инфра, не прояснившаяся за N попыток, всё равно ложно + откатывается как код-фейл). Заменён на infra-HOLD + алерт (D4), как ORCH-110 D3. +- **Двухсторонняя классификация (как ORCH-115)** — отвергнуто: не отличает постоянный + environment-дефект (ретрай бессмыслен, нужен немедленный HOLD) от транзиентной инфры (ретрай + осмыслен). Трёхсторонняя (D3) различает (FR-3). + +## Последствия + +- **+** На `deploy-staging` для `orchestrator` staging-сюита **реально исполняется в проде** + (host-side через доверенный ssh-канал); задача уровня ORCH-116 доходит до `deploy`, а не + откатывается (BR-1 / AC-2). +- **+** **Инфра/environment ≠ код-фейл** на staging-ребре: ни environment-дефект, ни транзиентная + инфра не дают ложного отката `→ development` и не жгут developer-retry (BR-2). Закрыт RCA-класс + ORCH-110 на staging-ребре полностью (не только частично, как ORCH-115 D5). +- **+** Анти-over-tolerance цел: реально исполнившаяся упавшая сюита по-прежнему откатывает (BR-3). +- **+** Переиспользует существующий доверенный ssh-канал (ORCH-036/058) — **без** расширения + привилегий, **без** docker CLI/SDK в контейнере (NFR-3); согласовано с задокументированным + инвариантом `image_freshness`. +- **+** Полная обратимость двумя флагами (`staging_runner_enabled` → LLM; + `staging_runner_exec_host_side` → in-container); контракт/гейт/ребро/схема БД неизменны. +- **−** Remote tree-kill ограничен локальным ssh-клиентом (удалённое `docker exec`-дерево не + гарантированно убивается tree-kill'ом) — **то же** допущение, что у принятого + `image_freshness.rebuild_staging_image`; backstop — bounded таймаут внутри `staging_check.py`. +- **−** Permanent-env / исчерпавшая-DEFER задача **держится** на `deploy-staging` до починки + окружения оператором → блокирует serial-gate репо (ORCH-088) для **более поздних** задач. + Принятый tradeoff (зеркало ORCH-110 infra-HOLD): environment-проблема, требующая оператора, + **должна** остановить репо, а не молча ложно-откатывать; скоуп self-hosting only. +- **−** Классификация по stderr-маркерам имеет остаточную неоднозначность. Митигейшн: fail-safe + default → `transient-infra` (не `suite-ran`); распознанный suite-exit без env-маркера всегда + доверяется (BR-3); набор маркеров — по образцу `merge_gate` scope-guard, под покрытием TC-06. +- **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` → прежний LLM-`deployer` на `deploy-staging` + байт-в-байт; либо `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE=false` → прежний in-container `docker exec`. + +## Ссылки +- BRD: `docs/work-items/ORCH-123/01-brd.md`; ТЗ: `02-trz.md`; Acceptance: `03-acceptance-criteria.md`; + Test-plan: `04-test-plan.yaml` +- Инфра: `docs/work-items/ORCH-123/07-infra-requirements.md`; Риски: `10-tech-risks.md` + (данные — `08` N/A: схема БД не меняется, TRZ §5) +- Сквозной ADR: `docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md` +- Амендит / опирается на: ORCH-115 (`docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`, + `adr-0048`) — стратегия исполнения D3 и терминал D5; ORCH-036 (`adr-0007-executable-self-deploy.md` / + `self_deploy` ssh-механизм), ORCH-058 (`adr-0008-staging-image-provenance.md` / `image_freshness` + host-side docker), ORCH-110 (`adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md` / proc_group + + infra-tolerance + classify), ORCH-101 (`adr-0036-replication-foundation-host-parametrization.md` / + host-параметризация) +- Сверено по коду: `src/staging_runner.py:154/415/487/528`, `src/self_deploy.py:220/287`, + `src/image_freshness.py:185/246`, `scripts/orchestrator-deploy-hook.sh:166/197`, + `src/proc_group.py`, `src/merge_gate.py:229`, `src/agents/launcher.py:406/438`, + `src/main.py:16/58/73`, `Dockerfile:11`, `docker-compose.yml` (docker.sock + ssh mounts), + `src/config.py:301-304/455-459` diff --git a/docs/work-items/ORCH-123/07-infra-requirements.md b/docs/work-items/ORCH-123/07-infra-requirements.md new file mode 100644 index 0000000..080857d --- /dev/null +++ b/docs/work-items/ORCH-123/07-infra-requirements.md @@ -0,0 +1,85 @@ +--- +work_item: ORCH-123 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-123 — host-side исполнение staging-раннера + +Work Item: **ORCH-123** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable. **Топология не меняется** (новых контейнеров/портов/томов/compose-правок нет). +> Файл фиксирует **границу исполнения** (BR-5/FR-6/AC-12) и **предусловия канала**, на которые +> раннер теперь опирается программно после фикса (раньше тот же `docker exec` исполнялся изнутри +> контейнера — и поэтому всегда падал в проде). + +## I-1. Граница исполнения docker-операций (нормативно — BR-5 / FR-6) + +**Прод-контейнер `orchestrator` (8500) НЕ содержит docker CLI.** `Dockerfile:11` ставит +`openssh-client git curl ca-certificates` (+ pinned gitleaks); `python:3.12-slim` docker не несёт. +`/var/run/docker.sock` **смонтирован** (`docker-compose.yml`, rw + `group_add 999`, ORCH-040 «МИНА 1»), +но **изнутри контейнера не используется** (нет клиента — сознательно, см. ADR-001 D2: добавление +клиента/SDK = активация root-эквивалентного пути для всего, что исполняется в контейнере). + +**Все docker-операции исполняются host-side через ssh на `127.0.0.1`** (доверенный канал, ключ +смонтирован `${ORCH_HOST_SSH_DIR}:…/.ssh:ro`, `openssh-client` в образе): + +| Операция | Компонент | Канал (host-side) | +|----------|-----------|-------------------| +| Прод-деплой (рестарт 8500) | `self_deploy.build_deploy_command` (ORCH-036) | `ssh + setsid bash --deploy` | +| Ребилд staging-образа из validated commit | `image_freshness.rebuild_staging_image` (ORCH-058) | `ssh … bash --build-staging` | +| Инспекция revision-label образа | `image_freshness.image_revision(ssh_target=…)` (ORCH-058) | `ssh … docker image inspect …` | +| **Staging-сюита на `deploy-staging`** | **`staging_runner.build_staging_command` (ORCH-123)** | **`ssh … docker exec orchestrator-staging python3 staging_check.py …`** | + +`scripts/staging_check.py` по-прежнему исполняется **внутри** `orchestrator-staging` (8501) — это +контракт сюиты (там python3 + приложение 8501, bind-mount `/repos/orchestrator/scripts/…`, ORCH-048). +**Меняется только инициатор `docker exec`** (host-side ssh вместо изнутри прод-контейнера), не сама +сюита и не её exit-код-контракт. + +## I-2. Предусловия канала исполнения + +Раннер после фикса программно опирается на (preflight их пробит на старте сервиса — ADR-001 D5): +- ssh-доступность `deploy_ssh_host` (`127.0.0.1`) под смонтированным ключом (та же, что использует + прод-деплой ORCH-036 / image-freshness ORCH-058 — уже в проде); +- наличие `docker` CLI **на хосте** (есть; контейнер его не несёт); +- поднятый и доступный staging-контейнер `orchestrator-staging` (8501) (Допущение А1 ORCH-115). + +Недоступность любого предусловия классифицируется **детерминированно** (ADR-001 D3): постоянный +дефект (`docker`/контейнер/ssh недоступны как класс) → `permanent-env` → **infra-HOLD + алерт** +(никогда тихий advance / ложный green / откат как код-фейл); транзиентная икота (timeout/ssh-255) → +bounded DEFER. + +## I-3. Переменные окружения / секреты + +**Новый env-ключ** (config, дефолт = боевое; добавить в `.env.example`): +- `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE` (`staging_runner_exec_host_side`, дефолт `True`) — выбор + стратегии исполнения. `True` → host-side ssh (фикс). `False` → прежний in-container `docker exec` + (валиден только там, где docker CLI запечён в образ). Откат фикса — без LLM-пути. + +**Переиспользуются (без изменений):** `ORCH_STAGING_RUNNER_ENABLED`/`_REPOS`/`_TIMEOUT_S`/ +`_INFRA_MAX_RETRIES`/`_INFRA_RETRY_DELAY_S` (ORCH-115); `ORCH_DEPLOY_SSH_USER`/`ORCH_DEPLOY_SSH_HOST`/ +`ORCH_DEPLOY_HOST_REPO_PATH` (ssh-канал ORCH-036); `ORCH_STAGING_PORT`/`ORCH_REPOS_DIR` (ORCH-101). +**Секретов не вводит** — переиспользует существующий смонтированный ssh-ключ; команда строится из +существующих host-параметров (анти-дрейф `tests/test_no_host_hardcodes.py`). + +## I-4. Деплой / рестарт (self-hosting инвариант, NFR-2 / AC-8) + +Раннер на `deploy-staging` **никогда** не рестартит прод-8500, не выполняет +`docker compose up orchestrator`/`--build` прода, не пушит force в `main`, не правит `.env`/ +`docker-compose.yml`/`Dockerfile` (BR-7 ORCH-115 / AC-8; структурный анти-литерал-тест на +ssh-обёрнутой команде — TC-08). Argv содержит только `ssh … docker exec orchestrator-staging python3 +staging_check.py … --mode stub`. ORCH-040 (смонтированный сокет) **не трогается и не расширяется**. +Прод-выкат самого ORCH-123 — штатно через staging-гейт (8501) → `Confirm Deploy` (ORCH-059); это +**docs/код+config**-изменение, активируется на следующем worktree от `main`, включается флагом +(дефолт `True`, обратимо двумя флагами — ADR-001 D6). + +## I-5. CI/CD + +**Без изменений `.gitea/workflows/`.** Новый/расширенный `tests/test_orch123_staging_runner_exec.py` +исполняется существующим `pytest tests/ -q` (моки subprocess/ssh-spawn и `advance_stage`; живой +Claude CLI / docker / ssh / сеть не требуются — окружение «нет docker CLI» моделируется монки-патчем +argv/spawn, `04-test-plan.yaml` notes). Существующие `tests/test_orch115_staging_runner.py` остаются +зелёными (TC-14). diff --git a/docs/work-items/ORCH-123/10-tech-risks.md b/docs/work-items/ORCH-123/10-tech-risks.md new file mode 100644 index 0000000..aab8556 --- /dev/null +++ b/docs/work-items/ORCH-123/10-tech-risks.md @@ -0,0 +1,93 @@ +--- +work_item: ORCH-123 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-123 — host-side исполнение staging-раннера + +Work Item: **ORCH-123** · Repo: **orchestrator** · Стадия: architecture + +Формат: риск → вероятность/влияние → митигация (привязка к решению ADR-001 / AC). + +--- + +## R-1 — Security: расширение поверхности атаки docker (NFR-3 / AC-9) +**Влияние:** критичное (root-эквивалент на общем хосте). **Вероятность (при выбранной стратегии):** +низкая. +Любой вариант с docker CLI/SDK **в контейнере** поверх смонтированного `docker.sock` (rw + group_add +999, ORCH-040) превращает дремлющую возможность в активный root-эквивалентный путь, доступный +worker-коду **и любому subprocess'у** (вкл. LLM-агентов). +**Митигация:** выбран **host-side ssh** (ADR-001 D1/D2) — docker-операции на хосте под существующим +доверенным key-gated каналом (ORCH-036/058), **без** docker CLI/SDK в контейнере и **без** расширения +использования сокета. ORCH-123 поверхность ORCH-040 не трогает. Любой будущий socket/CLI-в-контейнере +вариант — отдельный явный security-review. + +## R-2 — Реконсилятор/reaper откатывает infra-held задачу как код-фейл (FR-2 / AC-3 / AC-11) +**Влияние:** высокое (реинтродукция дефекта ORCH-123). **Вероятность:** средняя (зависит от +поведения reconciler ORCH-053 на застрявшем `deploy-staging`). +`permanent-env` / исчерпавшая-DEFER задача держится на `deploy-staging` (ADR-001 D4). Если reconciler/ +reaper трактует «застрял на deploy-staging без running-job» как повод откатить на `development` — +дефект вернётся. +**Митигация:** developer **обязан** верифицировать, что reconciler/reaper трактует infra-held +deploy-staging-задачу как **удержанную** (пере-выдать `deployer`-джоб для re-drive после починки +окружения), а **не** откатывает в `development`. Регресс-покрытие — поведенческий тест на «held не +становится rollback». Наблюдаемость (D8) делает held видимым (alert + `GET /queue` + лог). + +## R-3 — Over-tolerance: реальный код-фейл сюиты замаскирован как «инфра» (BR-3 / AC-4) +**Влияние:** высокое (сломанный код проходит staging-гейт). **Вероятность:** низкая. +Трёхсторонняя классификация (D3) может ошибочно отнести реальный `exit≠0` сюиты к +`permanent-env`/`transient-infra` (→ нет отката) при коллизии exit-кодов +(`docker exec` No such container = 1, и `staging_check` fail = 1). +**Митигация (D3):** дизамбигуация по **скану stderr на env-маркеры**, не по голому exit-коду: +распознанный suite-exit **без** env-маркера всегда `suite-ran` (никогда не реклассифицируется в +инфру — зеркало ORCH-110 BR-6). Fail-safe default при неоднозначности → `transient-infra` (DEFER), не +тихий проход. Маркер-сет покрыт TC-06; reviewer проверяет анти-over-tolerance (≥P1, как ORCH-110). + +## R-4 — Remote tree-kill: удалённое `docker exec`-дерево переживает таймаут (NFR-5) +**Влияние:** среднее (осиротевший pytest на хосте — класс ORCH-109/110). **Вероятность:** низкая. +`proc_group` tree-kill убивает **локальный** ssh-клиент при таймауте, но не гарантирует убийство +удалённого `docker exec`-дерева на хосте (процессы — дети docker-демона, не ssh-сессии). +**Митигация:** backstop — bounded таймаут внутри самой `staging_check.py`; это **то же** принятое +допущение, что у `image_freshness.rebuild_staging_image` (синхронный ssh без remote tree-kill). +Документируется как known-limitation (ADR-001 D-Последствия); не блокер. + +## R-5 — Held-задача клинит serial-gate репо (NFR-5 / AC-11) +**Влияние:** среднее (более поздние задачи репо ждут). **Вероятность:** средняя (при реальном +environment-сбое). +infra-HOLD держит задачу на `deploy-staging` → ORCH-088 serial-gate блокирует **более поздние** задачи +того же репо до снятия. +**Митигация:** принятый tradeoff (зеркало ORCH-110 infra-HOLD): environment-проблема, требующая +оператора, **должна** остановить репо, а не молча ложно-откатывать (что было бы хуже — порча +done-критериев + расход developer-retry). Скоуп **self-hosting only** (enduro не затронут). Громкий +alert «инфра, НЕ дефект кода» (D8) → быстрая операторская реакция; `GET /queue` показывает held. + +## R-6 — ssh-target не сконфигурирован / пустой `deploy_ssh_host` (FR-1 / D6) +**Влияние:** среднее. **Вероятность:** низкая (в проде `ORCH_DEPLOY_SSH_HOST=127.0.0.1` задан compose). +Config-дефолт `deploy_ssh_host=""`; на нестандартном хосте host-side стратегия без ssh-target +неработоспособна. +**Митигация:** пустой ssh-target → классификация `permanent-env` (D3) → infra-HOLD + alert (никогда +ложный откат); preflight на старте (D5) ловит это до реальной задачи. `applies(repo)` self-hosting-only +→ прочие репо не затронуты. + +## R-7 — Кросс-каттинг: правка маркированных блоков (CLAUDE.md §9 / ORCH-078) +**Влияние:** среднее. **Вероятность:** низкая. +Затрагиваются блоки с маркерами ORCH-115 (staging_runner, прямой объект), ORCH-036 (`self_deploy` +ssh), ORCH-058 (`image_freshness` host-side docker), ORCH-110 (`proc_group`/infra-tolerance/classify), +ORCH-101 (host-параметризация), ORCH-040 (docker.sock mount), ORCH-114 (transition-lease граница). +**Митигация:** developer **перед** изменением сверяет инварианты с их `06-adr/` (CLAUDE.md §9). Блок с +≥3 маркерами агрегируется **сводным сквозным ADR** `adr-0049` (ADR-001 Сквозная регистрация). Не +ломать: один транспорт LLM `_spawn` (`llm-usage-policy.md` §5), `STAGE_TRANSITIONS`/`QG_CHECKS`/ +machine-verdict (NFR-1), transition-lease берётся внутри `advance_stage` (граница O1), INV-4 (мерж +только через PR-merge, никогда push в `main`). + +## R-8 — Регрессия конвейера / гейта (NFR-1 / AC-7) +**Влияние:** критичное. **Вероятность:** очень низкая. +Случайное изменение `check_staging_status`/`_parse_staging_status`/`staging_status:`/`STAGE_TRANSITIONS`/ +схемы БД. +**Митигация:** фикс — замена **стратегии исполнения продюсера**, не гейта (зеркало ORCH-115 NFR-1). +Структурный анти-регресс-тест (TC-11) держит имена/семантику/ключи байт-в-байт; миграции БД нет +(restart-safe DEFER-счётчик — маркер в `task_content`, TRZ §5). Анти-дрейф ORCH-115 (TC-14) зелёный.