---
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` (заполняет архитектор).
