--- work_item: ORCH-117 stage: analysis author_agent: analyst status: ready-for-review created_at: 2026-06-15 model_used: claude-opus-4-8 escalate: full-cycle --- # 01 — BRD / Bug-report: ORCH-117 — test/staging Plane writes must be sandbox-only and never mutate prod Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle** > ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Задача помечена `Bug`, но по сути это > **архитектурный + safety-critical (self-hosting)** дефект изоляции окружений: нужно решение о > том, **где** ставить fail-closed-чокпоинт записи в Plane, **как** детектировать тест-процесс > (pytest/worktree) в отличие от staging-runtime, и **как** устроен явный аудируемый opt-in для > sandbox-интеграции. Это требует ADR (политика изоляции + точка перехвата). Поэтому выпускается > **полный** analysis-пакет, а не облегчённый bug-пакет. Оператор снимает багфикс-трек: > `POST /bug-fast-track/escalate?work_item=ORCH-117` → задача пойдёт через стадию `architecture` > (architect выпустит ADR для политики изоляции/чокпоинта). --- ## 1. Бизнес-контекст и проблема ### Симптом (наблюдаемое — установленный факт из инцидента ORCH-114) Во время тестирования ORCH-114 **тестовый/worktree-путь выполнил РЕАЛЬНУЮ запись в Plane против ПРОДАКШН-проекта ORCH**. В логах Plane зафиксировано: ``` PATCH /issues/dd57ad23... state=3738cd3c... # 3738cd3c == Done + comment: "Stage: deploy → done" (branch feature/orch114) ``` То есть `notify_stage_change("ORCH-114", "deploy", "done")`, запущенный из тестового процесса, смутировал боевую задачу в Plane — поставил **Done** и оставил комментарий. Это «ложный Done»: прод-доска показала задачу завершённой по действию тестов, а не конвейера. ### Причина симптома (установленный факт, верифицировано в коде) Тест/staging-процессы **имеют доступ к живому Plane-токену и workspace** и **не принуждаются** писать только в ORCH Sandbox: 1. **Токен берётся из боевого окружения контейнера.** В `src/plane_sync.py:17` `PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` фиксируется **на импорте модуля**; `settings.plane_api_token` читается из env контейнера, где боевой токен **уже установлен**. 2. **Защита в тестах не работает.** Тестовые модули делают `os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")` (например `tests/test_deploy_terminal_sync.py:19`, `tests/test_orch026_serialize_integration.py:16` и ~20 др.). `os.environ.setdefault` **НЕ перекрывает** уже установленную в контейнере переменную → в проде это **no-op** → тесты наследуют **реальный** токен. Вдобавок, даже если бы env переписывался ПОСЛЕ импорта `plane_sync`, `PLANE_HEADERS` уже захвачен литералом на импорте и не пересчитывается (см. п.1) — подмена токена постфактум бесполезна. 3. **Целевой проект по умолчанию — боевой.** `src/plane_sync.py:57` `PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"` — дефолт указывает на **боевой ORCH-проект**; ничто не принуждает тест-процесс адресовать только sandbox `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (identifier `SANDBOX`). 4. **Нет fail-closed-гарда на путях записи.** Все мутирующие вызовы Plane проходят через **три** примитива в `src/plane_sync.py` — `update_issue_state` (`httpx.patch`, стр. 861), `add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) — и **ни один** не проверяет, выполняется ли он в тест-процессе и легитимен ли целевой проект. ### Прецедент в кодовой базе (почему фикс уместен и как его форму подсказать) `tests/conftest.py` уже содержит **ровно тот же класс защиты для Telegram**: autouse-фикстура `_no_telegram` глушит `send_telegram`, потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения Славе» (дословно из докстринга conftest). Аналогичная autouse-страховка для **Plane-записи** **отсутствует** — это пробел того же рода, который и реализовался инцидентом ORCH-114. Sandbox как понятие уже существует: `scripts/staging_check.py:283` фиксирует `SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`, а проверка B6 утверждает инвариант «sandbox present ∧ prod-ET absent ∧ prod-ORCH absent» — но **только как read-only верификация доступа**, а не как **гард записи**. ### Локализация (куда смотреть архитектору/разработчику) - **Чокпоинт записи** — три примитива в `src/plane_sync.py` (`update_issue_state`, `add_comment`, `_set_issue_state_direct`); все `set_issue_*`/`notify_*` сводятся к ним. Гард логично ставить максимально близко к фактическому `httpx.patch/post` (низкий чокпоинт ловит любой путь, включая будущие). - **Захват токена на импорте** — `PLANE_HEADERS`/`PROJECT_ID` модульного уровня (`plane_sync.py:17,57`): подмена env после импорта не лечит; гард обязан перехватывать **на момент вызова**. - **Дефолт тестового окружения** — `tests/conftest.py` (autouse, fail-closed по образцу `_no_telegram`). - **Конфиг opt-in** — `src/config.py` (новые ключи интеграционного включения + sandbox-allowlist). - **Детект тест-процесса** — в `src/` сейчас **нет** механизма (`PYTEST_CURRENT_TEST`/`sys.modules` не используются для этого); его предстоит ввести и/или опереть на явный конфиг-флаг. **Вывод:** устойчивость должна быть на стороне системы — запись в Plane из тест/worktree-процесса в **боевой** проект должна быть **физически невозможна** (fail-closed), независимо от того, какой токен оказался в окружении; sandbox-запись разрешается только при **явном аудируемом opt-in** и **только** в проект SANDBOX. ## 2. Объём (scope) ### В объёме - **Жёсткая fail-closed изоляция записи в Plane:** прогон unit/test/full-regression (pytest, в т.ч. из worktree) **не может** мутировать боевые Plane-проекты (state-PATCH и/или comment-POST) — даже при наличии **живого боевого токена** в окружении. - **Sandbox-only для реальных тестов:** staging / full-real e2e-тесты, которым нужна настоящая запись в Plane, адресуют **только** проект ORCH Sandbox (`8c5a3025-…`); любой другой целевой проект (особенно боевой `7a79f0a9-…`) — запрещён. - **Явный аудируемый opt-in:** запись в Plane из тест-процесса возможна **исключительно** при одновременном выполнении: (а) включён выделенный интеграционный флаг, (б) целевой проект ∈ sandbox-allowlist. Отсутствие любого условия → запись блокируется. - **Дефолт тестов fail-closed:** autouse-страховка в `tests/conftest.py` (по образцу `_no_telegram`) блокирует Plane-запись по умолчанию во **всех** тестах. - **Наблюдаемость/аудит:** каждая заблокированная запись логируется структурно (WARNING/ERROR с целевым project_id, work_item, операцией); каждая разрешённая sandbox-запись — audit-строкой. - **Док/конфиг:** обновить `.env.example`, `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md` (и, при необходимости, `docs/deployment/*` про тестовую изоляцию). - **Обязательный регресс-тест:** воспроизводит инцидент ORCH-114 — красный до фикса, зелёный после. ### Вне объёма - ❌ Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД (это bugfix-изоляция, а **не** Quality Gate и **не** стадия). - ❌ Изменение поведения **боевого рантайма** оркестратора (не-pytest процесс): прод обязан писать в Plane как прежде, гард для него — no-op. - ❌ Изменение поведения **staging-рантайма** (8501): staging — реальный процесс оркестратора, работающий **только** с sandbox-проектом по конфигу; он должен по-прежнему писать в SANDBOX. - ❌ Запрет/контроль ручных операций оператора (вне технической власти системы). - ❌ Выбор конкретного механизма детекта тест-процесса и точки перехвата (гард в `plane_sync` vs обёртка `httpx` vs autouse-фикстура vs комбинация) — **зона архитектора** (ADR). - ❌ Массовая чистка/нормализация существующих `os.environ.setdefault(...)` строк в тестах сверх необходимого (можно оставить как есть — гард не должен от них зависеть; см. NFR-4). ## 3. Заинтересованные стороны - **Заказчик/оператор (Слава)** — страдает от «ложных Done» и шумных комментариев в боевой Plane, порождённых тестами; принимает результат. - **Self-hosting конвейер orchestrator** — прямой потребитель: целостность боевой Plane-доски как источника индикации (слой B, ORCH-066) не должна искажаться тест-прогонами. - **Все проекты на общем инстансе (enduro-trails)** — косвенно: тестовая запись не должна задевать чужие боевые проекты в общем workspace. - **Разработчики/CI** — потребители sandbox-e2e: должны сохранить возможность реальной проверки против SANDBOX. ## 4. Бизнес-требования (BR) - **BR-1** — Прогон pytest (unit/integration/full-regression), в том числе из worktree, **НЕ должен** выполнять мутирующую запись в Plane (state-PATCH и/или comment-POST) против **боевых** проектов — **даже при наличии живого боевого токена** в окружении. Это **fail-closed** свойство: запись в боевой проект из тест-процесса **невозможна**. - **BR-2** — Реальная запись в Plane из тест/staging-контекста разрешена **только** в проект **ORCH Sandbox** (`8c5a3025-4f9d-4190-b79f-fa06276bb27e`); любой иной целевой проект (в т.ч. боевой ORCH `7a79f0a9-…` и боевой enduro-проект) — запрещён. - **BR-3** — Реальная sandbox-запись из тест-процесса включается **только** явным аудируемым opt-in: одновременно (а) выделенный интеграционный флаг включён **и** (б) целевой проект ∈ sandbox-allowlist. Отсутствие любого условия → запись блокируется (default-deny). - **BR-4** — Дефолтная тестовая поза — **fail-closed**: при обычном `pytest tests/` (без явного opt-in) **ни один** тест не может писать в Plane (autouse-страховка в `conftest.py`, по образцу существующего `_no_telegram`). - **BR-5** — **Sandbox e2e сохраняется:** с включённым opt-in и целевым проектом SANDBOX реальная запись в Plane успешно проходит (регрессии sandbox-сценария нет). - **BR-6** — **Наблюдаемость/аудит:** каждая заблокированная попытка записи логируется структурно (целевой project_id, work_item, операция, причина блокировки); каждая разрешённая sandbox-запись — audit-строкой. Инцидент класса ORCH-114 должен быть **видимым**, а не молчаливым. - **BR-7** — **Документация и конфиг обновлены** в том же PR (golden source наравне с кодом): `.env.example`, `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`. ## 5. Нефункциональные требования (NFR) - **NFR-1 (fail-closed / default-deny)** — при любой неопределённости (не удаётся достоверно определить целевой проект / окружение / тест-контекст) запись в тест-контексте **запрещается**. «Не знаю» ⇒ «не пишу». - **NFR-2 (нулевая регрессия боевого рантайма)** — реальный прод-процесс оркестратора (не pytest) пишет в Plane **байт-в-байт** как до ORCH-117; гард для него — no-op. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**. - **NFR-3 (staging-рантайм не сломан)** — staging-инстанс (8501) — **реальный** процесс оркестратора (не pytest), сконфигурированный на sandbox-проект; он должен **по-прежнему** писать в SANDBOX. Детект обязан отличать **тест-процесс (pytest/worktree)** от **staging-runtime**. - **NFR-4 (устойчивость к захвату токена на импорте)** — фикс **не должен** полагаться на подмену `settings.plane_api_token`/env постфактум (бесполезно из-за модульного захвата `PLANE_HEADERS`/ `PROJECT_ID`, `plane_sync.py:17,57`) и **не должен** зависеть от неработающего `os.environ.setdefault(...)` в тестах. Перехват — **на момент вызова** примитива записи. - **NFR-5 (надёжность / self-hosting safety)** — гард изолирован и **never-raise в боевом пути** (по образцу leaf'ов `serial_gate`/`cancel`/`deploy_status_guard`): сбой/недоступность логики гарда не роняет боевой конвейер и не блокирует легитимную боевую запись. В **тест-процессе** срабатывание гарда должно быть **громким** (блок + аудит, при необходимости — жёсткий fail), чтобы дефект всплыл, а не замаскировался. - **NFR-6 (обратимость / kill-switch)** — поведение под флагом по конвенции проекта, **но** дефолт = **безопасный** (fail-closed в тестах). Kill-switch **не должен** позволять случайно переоткрыть запись в боевой проект из тестов без явного аудируемого opt-in (BR-3); т.е. «выключить защиту полностью» не равно «разрешить запись в прод из pytest». - **NFR-7 (область / композиция)** — изменение скоупится на изоляцию тест/staging-записи; не ухудшает поведение для прочих репо/боевого рантайма; совместимо с ORCH-066 (статусная модель), ORCH-094 (deploy-status guard), ORCH-061 (sandbox-infra tolerance staging_check). ## 6. Допущения и ограничения - **Все** мутирующие записи в Plane проходят через 3 примитива `src/plane_sync.py` (`update_issue_state`, `add_comment`, `_set_issue_state_direct`) — это единый узкий чокпоинт (верифицировано: все `set_issue_*`/`notify_*` сводятся к ним). - Боевой проект ORCH = `7a79f0a9-5278-49cd-9007-9a338f238f9c` (дефолт `PROJECT_ID`); sandbox = `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (`SANDBOX`, уже зафиксирован в `scripts/staging_check.py:283`). - `PLANE_HEADERS`/`PROJECT_ID` захватываются на импорте модуля — гард обязан читать актуальное состояние/контекст **в момент вызова**, не на импорте. - Тест-процесс достоверно отличим (например по `PYTEST_CURRENT_TEST` в env, по наличию `pytest` в `sys.modules`, и/или по явному конфиг-флагу тест-режима) — **выбор признака — вопрос ADR**; признак должен быть надёжным и не давать ложноположительных срабатываний в боевом/staging рантайме (NFR-2/NFR-3). - Конкретный механизм (гард-leaf в `plane_sync` / обёртка над `httpx` / autouse-фикстура / их комбинация) и протокол opt-in — **открытый вопрос архитектуры**, решается в `06-adr/`. ## 7. Критерии успеха Прогон pytest с **живым боевым токеном** в окружении **физически не может** смутировать боевой ORCH-проект (0 PATCH/POST в боевой проект); sandbox-e2e против SANDBOX по-прежнему работает при явном opt-in; боевой и staging рантаймы — без регресса; каждая блокировка/разрешение записи — наблюдаема (аудит-лог); док/конфиг обновлены; обязательный регресс-тест **красный до фикса, зелёный после**. Детальные PASS/FAIL — `03-acceptance-criteria.md`. ## 8. Риски - **Ложноположительный детект тест-процесса** в боевом/staging рантайме → блокировка легитимной боевой/sandbox записи (молчаливая потеря индикации Plane). Митигирует NFR-2/NFR-3 + аудит (BR-6). - **Ложноотрицательный детект** (тест-процесс не распознан) → дефект остаётся → нужен надёжный признак + fail-closed по умолчанию (NFR-1) + дефолтная autouse-страховка (BR-4). - **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`): неверная точка перехвата (на импорте, а не на вызове) даст ложное чувство защиты — жёсткое ограничение для архитектора (NFR-4). - **Kill-switch как чёрный ход:** грубо реализованный «выключатель» может переоткрыть запись в прод из тестов — запрещено (NFR-6). - Кросс-каттинг с ORCH-066 (Plane-индикация), ORCH-094 (deploy-status guard), ORCH-061 (staging sandbox-infra). Детали/митигации — `10-tech-risks.md` (заполняет архитектор).