architect(ET): auto-commit from architect run_id=717
All checks were successful
CI / test (push) Successful in 1m10s
All checks were successful
CI / test (push) Successful in 1m10s
This commit is contained in:
File diff suppressed because one or more lines are too long
121
docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md
Normal file
121
docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
work_item: ORCH-117
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# adr-0046: Sandbox-only fail-closed гард записи в Plane из тест-процесса
|
||||
|
||||
Сквозной (cross-cutting) ADR. Вводит инвариант **«мутирующая запись в Plane из тест/worktree-процесса
|
||||
физически невозможна в боевой проект; sandbox — только под явным opt-in»** поверх **общего**
|
||||
Plane-клиента `src/plane_sync.py` (три примитива записи, используемые ВСЕМИ проектами общего
|
||||
инстанса) и нового тест-харнесс-инварианта `tests/conftest.py`. Детальное решение задачи —
|
||||
`docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`.
|
||||
|
||||
> Регистрируется как сквозной, т.к. правит **системно используемые** примитивы записи
|
||||
> `update_issue_state`/`add_comment`/`_set_issue_state_direct` и вводит новый рантайм-компонент
|
||||
> (leaf `src/plane_write_guard.py`), затрагивающий индикацию (слой B, ORCH-066) всех проектов.
|
||||
> Кросс-каттинг с adr-0028 (deploy-status guard, ORCH-094) и adr-0009 (staging-tolerance, ORCH-061):
|
||||
> оба — потребители того же `plane_sync`; гард для них — no-op в боевом/staging рантайме.
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
## Контекст
|
||||
|
||||
Инцидент **ORCH-114**: тестовый/worktree-процесс (`python -m pytest` из worktree) выполнил
|
||||
**реальную** запись в Plane против **боевого** проекта ORCH (`PATCH state=<Done>` + комментарий) —
|
||||
«ложный Done» на боевой доске. Корень (сверено по коду `src/plane_sync.py`):
|
||||
|
||||
1. `PLANE_HEADERS`/`PROJECT_ID` (боевой токен + боевой дефолтный проект) **захвачены на импорте**
|
||||
модуля (стр. 17/57) → подмена env/токена постфактум бесполезна.
|
||||
2. Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN",…)` — **no-op** в контейнере с уже
|
||||
установленной боевой переменной.
|
||||
3. Все мутации сходятся в **три** примитива (`update_issue_state`/`add_comment`/
|
||||
`_set_issue_state_direct`), и ни один **не** проверяет тест-контекст и легитимность целевого
|
||||
проекта.
|
||||
|
||||
Симметричная защита для Telegram (`tests/conftest.py::_no_telegram`) существует и работает по тому же
|
||||
классу проблем («pytest на проде слал реальные сообщения»); для Plane-записи её **не было**.
|
||||
|
||||
## Решение
|
||||
|
||||
**Fail-closed гард на низком чокпоинте**, в момент вызова, двумя независимыми sandbox-bound слоями.
|
||||
|
||||
### D1 — Рантайм-leaf `src/plane_write_guard.py` (never-raise)
|
||||
|
||||
Чистый leaf (паттерн `serial_gate`/`cancel`/`deploy_status_guard`): импортирует только `config`,
|
||||
лениво `db`. `decide(project_id, op, work_item_id) -> (ok: bool, reason: str)`:
|
||||
|
||||
1. `not _in_test_process()` → **ALLOW** (боевой/staging рантайм — no-op, byte-for-byte).
|
||||
2. `project_id` нерезолвим → **BLOCK** `ambiguous-target` (fail-closed, NFR-1).
|
||||
3. `not plane_test_write_enabled` → **BLOCK** `opt-in-disabled`.
|
||||
4. `project_id ∉ sandbox-allowlist` → **BLOCK** `prod-project-in-test` (sandbox-only даже при opt-in).
|
||||
5. иначе → **ALLOW** `sandbox-opt-in` (audit INFO).
|
||||
|
||||
Врезается в 3 примитива `plane_sync` сразу после `_resolve_project_id` и **до** любого сетевого шага;
|
||||
на BLOCK — структурный аудит + `return` (ни GET, ни PATCH/POST).
|
||||
|
||||
### D2 — Детект `_in_test_process()`
|
||||
|
||||
`"pytest" in sys.modules or PYTEST_CURRENT_TEST` (call-time). Боевой/staging рантайм
|
||||
(`uvicorn src.main:app`) pytest в свой процесс не импортирует → детект там никогда не срабатывает
|
||||
(нулевая регрессия). worktree-`python -m pytest` (инцидентный путь) детектируется гарантированно.
|
||||
|
||||
### D3 — Conftest-floor `tests/conftest.py::_plane_sandbox_only`
|
||||
|
||||
Autouse-фикстура (паттерн `_no_telegram`/`_reset_webhook_secrets`/`_disable_*`) форсит во ВСЕХ тестах
|
||||
безопасные дефолты (`plane_test_write_enabled=False`, allowlist = канонический SANDBOX id),
|
||||
перекрывая любую боевую переменную из окружения. Sandbox-e2e ре-энейблит opt-in **после** autouse
|
||||
(scoping реальной записи на себя). Слой независим от рантайм-leaf → двойной default-deny.
|
||||
|
||||
### D4 — Реверс через opt-in, БЕЗ kill-switch (норматив)
|
||||
|
||||
Единственный реверсивный регулятор — sandbox-bound opt-in `plane_test_write_enabled` (+ allowlist
|
||||
`plane_test_sandbox_projects`). **Намеренно нет** prod-блок kill-switch: выключатель, обнуляющий
|
||||
prod-блок в тест-процессе, был бы «чёрным ходом» (NFR-6). Прецедент — `_no_telegram` (тоже без
|
||||
«разрешить»-флага). **Анти-дрейф (норматив на будущее):** не вводить общий kill-switch гарда,
|
||||
переоткрывающий прод-запись из pytest.
|
||||
|
||||
### D5 — Скоуп: НЕ `*_repos`
|
||||
|
||||
В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate`, scope по репо, т.к. *действуют* на репо),
|
||||
гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro) → скоупа по
|
||||
репо нет; гейты — `_in_test_process()` + opt-in (как у observer-leaf `lessons`).
|
||||
|
||||
## Инварианты (что НЕ меняется)
|
||||
|
||||
`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи
|
||||
(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) /
|
||||
схема БД — **байт-в-байт не тронуты**. Это bugfix-изоляция клиента Plane, **не** Quality Gate и
|
||||
**не** стадия. Боевой и staging рантаймы — byte-for-byte (no-op гарда). adr-0028 (deploy-status
|
||||
guard) / adr-0009 (staging-tolerance) / ORCH-066 (статусная модель) в проде/стейджинге не затронуты.
|
||||
|
||||
## Конфиг
|
||||
|
||||
| Ключ | Env | Дефолт |
|
||||
|------|-----|--------|
|
||||
| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` |
|
||||
| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `8c5a3025-4f9d-4190-b79f-fa06276bb27e` |
|
||||
|
||||
## Последствия
|
||||
|
||||
- **+** Прод-запись в Plane из pytest/worktree физически невозможна независимо от токена; ORCH-114
|
||||
закрыт у источника и стал видимым (аудит).
|
||||
- **+** Нулевая регрессия боевого/staging рантайма и гейтов/схемы БД.
|
||||
- **−** Детект завязан на «pytest-в-процессе» (теоретический ложноположительный риск — TR-1) и
|
||||
умышленный отказ от kill-switch требует явной фиксации (TR-4). См. `10-tech-risks.md`.
|
||||
- **Откат:** снять врезку гарда + autouse-фикстуру + 2 конфиг-ключа → поведение до ORCH-117 (дефект
|
||||
возвращается).
|
||||
|
||||
## Ссылки
|
||||
- Детально: `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`
|
||||
- Риски: `docs/work-items/ORCH-117/10-tech-risks.md`
|
||||
- Связанные: [adr-0028](adr-0028-terminal-window-aware-deploy-status-guard.md) (ORCH-094),
|
||||
[adr-0009](adr-0009-staging-infra-tolerance.md) (ORCH-061),
|
||||
[adr-0034](adr-0034-lessons-journal.md) (observer-leaf без `*_repos`)
|
||||
- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py`,
|
||||
`scripts/staging_check.py:283`
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
work_item: ORCH-117
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-001: Sandbox-only fail-closed гард записи в Plane из тест-процесса
|
||||
|
||||
Work Item: **ORCH-117** — test/staging Plane writes must be sandbox-only and never mutate prod
|
||||
Стадия: **architecture**
|
||||
Сквозная регистрация: **`docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`**
|
||||
(кросс-каттинговое: вводит новый рантайм-leaf поверх **общего** Plane-клиента `plane_sync`,
|
||||
используемого ВСЕМИ проектами общего инстанса, + новый тест-харнесс-инвариант в `conftest.py`).
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
## Контекст
|
||||
|
||||
**Инцидент (установленный факт, ORCH-114).** Тестовый/worktree-процесс выполнил РЕАЛЬНУЮ запись в
|
||||
Plane против **боевого** проекта ORCH: `PATCH …/issues/dd57ad23… state=<Done>` + комментарий
|
||||
«Stage: deploy → done». То есть `notify_stage_change("ORCH-114","deploy","done")`, запущенный из
|
||||
pytest, смутировал боевую задачу — «ложный Done» на боевой доске (источник индикации, слой B
|
||||
ORCH-066).
|
||||
|
||||
**Корень (сверено по коду).** Тест/staging-процессы имеют доступ к живому боевому Plane-токену и
|
||||
**ничто не принуждает** их писать только в sandbox:
|
||||
|
||||
- `src/plane_sync.py:17` `PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` и `:57`
|
||||
`PROJECT_ID = settings.plane_project_id or "7a79f0a9-…"` (боевой ORCH) **захватываются на импорте
|
||||
модуля** → подмена env/токена постфактум бесполезна (NFR-4).
|
||||
- Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN","test-token")` — **no-op** в контейнере с
|
||||
уже установленной боевой переменной → тесты наследуют **реальный** токен.
|
||||
- Все мутирующие записи сходятся в **три** примитива: `update_issue_state` (`httpx.patch`, стр. 861),
|
||||
`add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) — и
|
||||
**ни один** не проверяет тест-контекст и легитимность целевого проекта.
|
||||
|
||||
**Прецедент в репозитории.** `tests/conftest.py::_no_telegram` — autouse-фикстура, глушащая
|
||||
`send_telegram` во ВСЕХ тестах, ровно потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения
|
||||
Славе». Симметричной защиты для **Plane-записи** не было — это пробел того же класса, реализованный
|
||||
ORCH-114. Идентификатор sandbox уже зафиксирован: `scripts/staging_check.py:283`
|
||||
`SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`.
|
||||
|
||||
**Почему «как есть» не годится.** Устойчивость стоит на стороне тестов (надеяться, что каждый тест
|
||||
замокает Plane), а не на стороне системы. Любой новый/будущий путь записи, забывший мок, снова
|
||||
смутирует боевую доску. Требуется **fail-closed**-инвариант: запись в боевой проект из
|
||||
тест/worktree-процесса должна быть **физически невозможна**, независимо от токена в окружении.
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
|
||||
Вводим **fail-closed гард записи в Plane на низком чокпоинте** — на входе трёх примитивов записи
|
||||
`plane_sync`, **в момент вызова** (не на импорте). Чистую логику держит **новый leaf
|
||||
`src/plane_write_guard.py`** (never-raise, по образцу `serial_gate`/`cancel`/`deploy_status_guard`):
|
||||
`decide(project_id, op, work_item_id) -> (ALLOW | BLOCK, reason)`. Гард активен **только в
|
||||
тест-процессе** (детект `pytest`-в-процессе) — для боевого и staging рантайма он **no-op**
|
||||
(byte-for-byte, NFR-2/NFR-3). В тест-процессе запись разрешена **исключительно** при
|
||||
одновременном (а) включённом opt-in-флаге **и** (б) целевом проекте ∈ sandbox-allowlist; иначе —
|
||||
блок (default-deny). Дополнительно — **независимый conftest-floor** (autouse-фикстура), который
|
||||
форсит безопасные дефолты во ВСЕХ тестах (BR-4). Изменение — bugfix-изоляция: **не** Quality Gate и
|
||||
**не** стадия; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт не
|
||||
тронуты.
|
||||
|
||||
### D1 — Точка перехвата: низкий чокпоинт в 3 примитивах `plane_sync`, на момент вызова
|
||||
|
||||
Гард врезается в `update_issue_state` / `add_comment` / `_set_issue_state_direct` **сразу после**
|
||||
`_resolve_project_id(...)` (локальное чтение БД) и **до** любого сетевого шага (`stage_to_state`,
|
||||
`find_issue_id`, `httpx.patch/post`):
|
||||
|
||||
```python
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
ok, reason = plane_write_guard.decide(project_id, "state", work_item_id) # op ∈ {"state","comment"}
|
||||
if not ok:
|
||||
plane_write_guard.audit_block(project_id, "state", work_item_id, reason)
|
||||
return # никакой сети — ни GET, ни PATCH/POST
|
||||
# ... обычный путь (ALLOW)
|
||||
```
|
||||
|
||||
**Почему этот чокпоинт (а не обёртка `httpx` и не только autouse-фикстура):**
|
||||
|
||||
- **Низкий и полный.** Все `set_issue_*`/`notify_*` сводятся к этим трём примитивам (верифицировано
|
||||
BRD §6) → один гард ловит любой путь, включая будущие (тот же довод, что у `deploy_status_guard` на
|
||||
входе сеттеров).
|
||||
- **На момент вызова → иммунитет к захвату на импорте (NFR-4/AC-7).** `PLANE_HEADERS`/`PROJECT_ID`
|
||||
захвачены литералами на импорте; гард читает контекст (тест-процесс + резолвенный `project_id`) при
|
||||
каждом вызове, поэтому защита не зависит от того, какой токен в `PLANE_HEADERS`, и не опирается на
|
||||
неработающий `os.environ.setdefault`.
|
||||
- **До сети.** Размещение до `find_issue_id`/`stage_to_state` исключает даже «безобидный» GET в
|
||||
боевой workspace из тестов.
|
||||
- Обёртка над транспортом `httpx` отвергнута (см. «Альтернативы») — она ниже уровня, на котором
|
||||
известны `project_id`/`work_item`/`op` для аудита, и хрупка к смене HTTP-клиента.
|
||||
|
||||
### D2 — Детект тест-процесса: `pytest`-в-процессе (call-time)
|
||||
|
||||
```python
|
||||
def _in_test_process() -> bool:
|
||||
import sys, os
|
||||
return ("pytest" in sys.modules) or bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
||||
```
|
||||
|
||||
- **`"pytest" in sys.modules`** истинно на всём прогоне pytest (collection + выполнение) в **этом**
|
||||
процессе. Боевой и staging рантаймы запускаются `uvicorn src.main:app` и **не импортируют** pytest
|
||||
в свой процесс → детект там **никогда** не срабатывает (NFR-2/NFR-3, AC-5/AC-6). pytest установлен в
|
||||
образе (для merge-gate/coverage re-test), но запускается **отдельным субпроцессом** `python -m
|
||||
pytest` — он-то и есть worktree-тест-процесс из инцидента, и его гард **должен** ловить (✓).
|
||||
- **`PYTEST_CURRENT_TEST`** — вторичный сигнал (выставляется pytest на время тела теста), добавлен как
|
||||
дешёвый подтверждающий признак; основной — `sys.modules`.
|
||||
- Оба читаются **в момент вызова** (NFR-4). Признак консервативный: ложноположительное срабатывание в
|
||||
боевом рантайме требует, чтобы кто-то импортировал `pytest` в процесс uvicorn — чего штатный
|
||||
entrypoint не делает (зафиксировано как допущение, см. `10-tech-risks.md` TR-1).
|
||||
|
||||
### D3 — Решение `decide`: default-deny, sandbox-allowlist, опт-ин
|
||||
|
||||
`decide(project_id, op, work_item_id) -> (bool ok, str reason)` — чистая, never-raise:
|
||||
|
||||
| Шаг | Условие | Исход | reason |
|
||||
|-----|---------|-------|--------|
|
||||
| 1 | `not _in_test_process()` | **ALLOW** | `live-runtime` (прод/staging — no-op, NFR-2/3) |
|
||||
| 2 | `project_id` пуст/None/нерезолвим | **BLOCK** | `ambiguous-target` (NFR-1: «не знаю → не пишу») |
|
||||
| 3 | `not settings.plane_test_write_enabled` | **BLOCK** | `opt-in-disabled` |
|
||||
| 4 | `project_id ∉ sandbox_allowlist` | **BLOCK** | `prod-project-in-test` |
|
||||
| 5 | иначе | **ALLOW** | `sandbox-opt-in` (audit INFO) |
|
||||
|
||||
- **Allowlist sandbox-only (BR-2, AC-3).** Шаг 4 запрещает боевой ORCH (`7a79f0a9-…`) и любой боевой
|
||||
enduro-проект **даже при включённом opt-in** — opt-in лишь снимает шаг 3, allowlist остаётся
|
||||
жёстким полом.
|
||||
- **Fail-closed по неопределённости (NFR-1, AC-4).** Нерезолвимый целевой проект → блок (шаг 2).
|
||||
- **never-raise (NFR-5).** Любое внутреннее исключение `decide` интерпретируется вызывающим примитивом
|
||||
по контексту: в боевом пути это уже ALLOW (шаг 1 не достигнут — `_in_test_process` False); в
|
||||
тест-пути исключение трактуется как **BLOCK** (громко, fail-closed) — дефект всплывает, а не
|
||||
маскируется. (Реализация: `decide` ловит свои ошибки и в тест-контексте возвращает
|
||||
`(False, "guard-error")`, в live-контексте — `(True, …)`.)
|
||||
|
||||
### D4 — Kill-switch: его нет (умышленно, NFR-6/FR-7) — реверс через opt-in
|
||||
|
||||
**Сознательное расхождение с конвенцией «у каждого leaf есть `*_enabled` kill-switch».** Гард,
|
||||
делающий прод-запись из pytest *физически невозможной*, **не должен** поставляться с конфигом,
|
||||
который её переоткрывает — это и есть «чёрный ход», запрещённый NFR-6. Прямой прецедент в репозитории:
|
||||
`_no_telegram` тоже **не имеет** флага «разрешить реальный Telegram в тестах» — это безусловный
|
||||
страховочный пол.
|
||||
|
||||
- **Единственный реверсивный регулятор — opt-in** `plane_test_write_enabled` (default `False` =
|
||||
безопасно) + allowlist `plane_test_sandbox_projects` (default = единственный SANDBOX id). Он
|
||||
управляет **только sandbox-записью**; его off-состояние — безопасный дефолт, on-состояние —
|
||||
sandbox-bound. «Выключить защиту» ≠ «разрешить прод из pytest»: такого перехода в дизайне **нет**.
|
||||
- Рантайм-leaf **инертен в боевом рантайме** по построению (`_in_test_process()` False) → отдельный
|
||||
«выключатель» для безопасности прода не нужен; leaf never-raises → не может уронить боевой путь.
|
||||
- **Норматив на будущее (анти-дрейф):** не добавлять «общий kill-switch гарда», обнуляющий
|
||||
prod-блок в тест-процессе — это реинтродуцирует дефект ORCH-114. Зафиксировано в `10-tech-risks.md`
|
||||
(TR-4) и в сквозном adr-0046.
|
||||
|
||||
### D5 — Conftest-floor: независимый default-deny во всех тестах (BR-4, FR-3)
|
||||
|
||||
Autouse-фикстура `tests/conftest.py::_plane_sandbox_only` (по образцу `_reset_webhook_secrets` /
|
||||
`_disable_merge_verify`) форсит безопасные дефолты для **каждого** теста через `monkeypatch`,
|
||||
**перекрывая** любую боевую переменную, унаследованную из окружения контейнера:
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plane_sandbox_only(monkeypatch):
|
||||
from src import config as _cfg
|
||||
monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", False, raising=False)
|
||||
monkeypatch.setattr(_cfg.settings, "plane_test_sandbox_projects",
|
||||
"8c5a3025-4f9d-4190-b79f-fa06276bb27e", raising=False)
|
||||
yield
|
||||
```
|
||||
|
||||
- С opt-in `False` гард блокирует **все** записи в тестах (и sandbox, и прод) → AC-4 default-deny.
|
||||
- **Sandbox-e2e переопределяет** в собственной фикстуре *после* autouse (точно как
|
||||
`test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги): `plane_test_write_enabled=True`
|
||||
(+ allowlist уже содержит sandbox) → запись в SANDBOX проходит (AC-2), в прод — по-прежнему блок
|
||||
(allowlist, AC-3).
|
||||
- Floor **независим от рантайм-логики**: даже если рантайм-leaf по ошибке вернёт ALLOW, инвариант
|
||||
«opt-in off» делает прод-запись из обычного pytest невозможной. Два слоя, оба sandbox-bound →
|
||||
ни один не способен разрешить прод-запись из pytest (двойной NFR-6).
|
||||
|
||||
### D6 — Конфиг-ключи
|
||||
|
||||
В `src/config.py` (дефолты = безопасные):
|
||||
|
||||
| Ключ | Env | Дефолт | Назначение |
|
||||
|------|-----|--------|------------|
|
||||
| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` | opt-in реальной записи из тест-процесса |
|
||||
| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `"8c5a3025-4f9d-4190-b79f-fa06276bb27e"` | CSV allowlist sandbox-проектов |
|
||||
|
||||
- **НЕ `*_repos`-scope.** В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate` *действуют* на
|
||||
репо), этот гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro,
|
||||
BR-2). Регуляторов scope по репо нет; единственные гейты — `_in_test_process()` (рантайм) + opt-in
|
||||
(как у observer-leaf `lessons`, который тоже не скоупится по репо). Зафиксировано в adr-0046.
|
||||
- `.env.example` дополняется обоими ключами с безопасными дефолтами (deliverable developer'а).
|
||||
|
||||
### D7 — Аудит/наблюдаемость (BR-6, FR-5, AC-8)
|
||||
|
||||
- **Блок** → структурный `logger.warning`/`error` с полями `project_id`, `work_item`, `op`
|
||||
(`state`/`comment`), `reason` (`prod-project-in-test`/`opt-in-disabled`/`ambiguous-target`/
|
||||
`guard-error`). Формулировка делает инцидент класса ORCH-114 **очевидным** (не молчаливым).
|
||||
- **Разрешённая sandbox-запись** → audit `logger.info` с `project_id` и `op`.
|
||||
- Новый эндпоинт **не вводится** (TRZ §4): состояние гарда (флаг/allowlist) при желании дорисовывается
|
||||
read-only в `GET /queue` — **необязательно**, оставлено на усмотрение developer'а.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Только autouse-фикстура в `conftest.py` (без рантайм-leaf)** — отвергнуто: не делает прод-запись
|
||||
*физически невозможной* (AC-7) — любой путь, обошедший мок/фикстуру (прямой импорт `plane_sync` в
|
||||
скрипте под pytest, sandbox-e2e с опечаткой проекта), снова смутирует прод. Нужен рантайм-floor на
|
||||
момент вызова. Фикстура остаётся как **дополнительный** независимый слой (D5), не единственный.
|
||||
- **Обёртка/монки над `httpx`-транспортом** — отвергнуто: уровень ниже, чем известны
|
||||
`project_id`/`work_item`/`op` (хуже аудит); хрупко к смене HTTP-клиента; ловит и легитимные GET.
|
||||
Низкий чокпоинт в примитивах точнее и стабильнее.
|
||||
- **Подмена `settings.plane_api_token`/env на тестовый токен** — отвергнуто прямо BRD/NFR-4:
|
||||
`PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте → подмена постфактум бесполезна; `setdefault`
|
||||
no-op в проде. Не лечит корень.
|
||||
- **Гонять тесты в sandbox-проекте по дефолту (сменить дефолтный `PROJECT_ID`)** — отвергнуто: не
|
||||
fail-closed (живой токен + забытый мок всё равно может адресовать прод явным `project_id`); не
|
||||
отличает прод от sandbox по *намерению*.
|
||||
- **Конвенциональный `plane_write_guard_enabled` kill-switch** — отвергнуто (D4): его off-состояние
|
||||
было бы «чёрным ходом» к прод-записи из pytest (NFR-6). Реверс обеспечивает opt-in.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **+** Прод-запись в Plane из любого pytest/worktree-процесса **физически невозможна** независимо от
|
||||
токена (AC-1/AC-7); инцидент класса ORCH-114 закрыт у источника и стал **видимым** (аудит).
|
||||
- **+** Боевой и staging рантаймы — **байт-в-байт** (no-op гарда, `_in_test_process()` False);
|
||||
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД не тронуты (NFR-2/NFR-3, AC-5/AC-6).
|
||||
- **+** Два независимых sandbox-bound слоя (рантайм-leaf + conftest-floor) → нет одиночной точки, чьё
|
||||
выключение переоткрывает прод (NFR-6).
|
||||
- **+** Sandbox-e2e сохранён (opt-in + allowlist, AC-2); `scripts/staging_check.py` Block C
|
||||
работоспособен.
|
||||
- **−** Детект тест-процесса завязан на «pytest-в-процессе» → теоретический ложноположительный риск,
|
||||
если кто-то импортирует `pytest` в боевой uvicorn-процесс (не делает штатный entrypoint). Митигейшн:
|
||||
TR-1, консервативный признак, аудит-видимость; в худшем случае под opt-in остаётся sandbox-запись.
|
||||
- **−** Намеренный отказ от kill-switch расходится с привычной конвенцией → требует явной фиксации,
|
||||
чтобы будущий агент не «добавил выключатель» (D4-норматив, TR-4, adr-0046).
|
||||
- **Откат:** удалить врезку гарда из 3 примитивов + autouse-фикстуру + 2 конфиг-ключа → поведение
|
||||
байт-в-байт до ORCH-117 (дефект возвращается). Частичный «мягкий» откат (oct-in `True` глобально) —
|
||||
**запрещён** как небезопасный (вернёт прод-риск только при условии allowlist; всё равно
|
||||
sandbox-bound).
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-117/01-brd.md`
|
||||
- TRZ: `docs/work-items/ORCH-117/02-trz.md`
|
||||
- Acceptance: `docs/work-items/ORCH-117/03-acceptance-criteria.md`
|
||||
- Tech-risks: `docs/work-items/ORCH-117/10-tech-risks.md`
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`
|
||||
- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py` (`_no_telegram`),
|
||||
`scripts/staging_check.py:283`, `src/deploy_status_guard.py` (образец leaf), `src/config.py`
|
||||
- Прецедент-инвариант: `_no_telegram` (autouse safety-floor), `docs/_standards/TRACEABILITY.md`
|
||||
41
docs/work-items/ORCH-117/10-tech-risks.md
Normal file
41
docs/work-items/ORCH-117/10-tech-risks.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
work_item: ORCH-117
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 10 — Технические риски: ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
|
||||
|
||||
Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> Информационный (гейтом не парсится). Риски реализации решения ADR-001 и их митигейшн.
|
||||
|
||||
## Реестр рисков
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| TR-1 | **Ложноположительный детект тест-процесса** в боевом/staging рантайме (`pytest` каким-то образом импортирован в процесс uvicorn) → блокировка легитимной боевой/sandbox записи (молчаливая потеря Plane-индикации, слой B ORCH-066). | Низ. | Сред. | Штатный entrypoint `uvicorn src.main:app` **не** импортирует `pytest`; merge-gate/coverage гоняют pytest **отдельным субпроцессом** (его блок легитимен). Признак консервативный (`sys.modules`+`PYTEST_CURRENT_TEST`), читается на момент вызова. Аудит-WARNING делает любой блок видимым (BR-6) → ложный блок в проде немедленно заметен, а не молчалив. Зафиксированное допущение: «прод-процесс не импортирует pytest». |
|
||||
| TR-2 | **Ложноотрицательный детект** (worktree-тест-процесс не распознан как pytest) → дефект ORCH-114 остаётся. | Низ. | Выс. | Инцидентный путь (worktree `python -m pytest`) **гарантированно** имеет `pytest` в `sys.modules`. Двойной слой: даже при сбое рантайм-leaf conftest-floor (D5) держит default-deny. Обязательный регресс-тест AC-1/TC-01 (красный до фикса) доказывает покрытие именно инцидентного пути. |
|
||||
| TR-3 | **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`, стр. 17/57): размещение гарда не там → ложное чувство защиты (NFR-4). | Низ. | Выс. | Архитектурно жёстко: гард — на момент **вызова** примитива, до сети, не зависит от токена/`os.environ.setdefault` (ADR-001 D1/D2). AC-7 проверяет это буквально. |
|
||||
| TR-4 | **Kill-switch как чёрный ход:** будущий агент «добавляет общий выключатель гарда», который в off-состоянии переоткрывает прод-запись из pytest (NFR-6). | Сред. | Выс. | Дизайн **умышленно без** prod-блок kill-switch (ADR-001 D4): реверс — только sandbox-bound opt-in. Норматив зафиксирован в ADR-001, adr-0046 и `10-tech-risks` как анти-дрейф; прецедент `_no_telegram` (тоже без «разрешить» флага). Reviewer ловит реинтродукцию выключателя как finding ≥P1. |
|
||||
| TR-5 | **Регрессия существующего сьюта:** autouse-фикстура `_plane_sandbox_only` ломает тесты, которые ждали реальную/иную запись или ассертили на мок-вызов. | Низ. | Сред. | Большинство тестов уже **мокируют** `plane_*`/`add_comment` (TRZ §7) → реальной записи и так нет; гард лишь делает это гарантией по умолчанию. Фикстура форсит лишь безопасные дефолты (opt-in off), не подменяет сами примитивы. AC-11 (полный регресс зелёный) — обязательный гейт. |
|
||||
| TR-6 | **Sandbox-e2e ложно блокируется** (opt-in не доезжает / порядок фикстур). | Низ. | Сред. | Прецедент уже отработан в репозитории: `test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги **после** autouse. AC-2 проверяет реальную sandbox-запись под opt-in; `staging_check.py` Block C — smoke. |
|
||||
| TR-7 | **Утечка GET до гарда:** даже без PATCH/POST примитив мог бы сходить в боевой workspace `find_issue_id`/`stage_to_state`. | Низ. | Низ. | Гард размещён **до** любого сетевого шага (сразу после локального `_resolve_project_id`) — ни GET, ни мутации (ADR-001 D1). |
|
||||
| TR-8 | **Кросс-каттинг с ORCH-066/094/061:** гард меняет поведение общего `plane_sync`, потенциально задевая deploy-status guard (ORCH-094), staging-tolerance (ORCH-061), статусную модель (ORCH-066). | Низ. | Сред. | Гард — no-op в боевом/staging рантайме (`_in_test_process()` False) → ORCH-094/061/066 в проде/стейджинге **не затронуты**. В тестах те фичи и так под своими дефолтами/моками. Маркер-инвариант не ломается (правка примитивов аддитивна, не трогает машинные ключи). |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Доминирующий класс — **корректность детекта тест-процесса** (TR-1/TR-2) и **анти-дрейф kill-switch**
|
||||
(TR-4). Все три закрываются архитектурно: консервативный признак «pytest-в-процессе» на момент
|
||||
вызова + двойной независимый sandbox-bound слой (рантайм-leaf + conftest-floor) + обязательный
|
||||
регресс-тест инцидентного пути (AC-1). Остаточный риск для прод-конвейера (self-hosting) — **низкий**:
|
||||
гард инертен в боевом и staging рантайме по построению и never-raises, поэтому не способен ни уронить
|
||||
конвейер, ни заблокировать легитимную боевую запись; худший реалистичный исход (ложный блок в проде)
|
||||
требует несуществующего в штатном entrypoint импорта pytest и был бы немедленно виден через аудит.
|
||||
|
||||
**Эскалация не требуется:** решение аддитивно, в границах принципов (Docker/SQLite/leaf-pattern/
|
||||
never-raise), не трогает `STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД, не вводит новую стадию/QG/компонент
|
||||
инфраструктуры. Лейбл `arch:major-change` не ставится; возврат в анализ не нужен.
|
||||
Reference in New Issue
Block a user