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