# ADR-001: Осмысленная статусная модель Plane (слой B)

**Work Item:** ORCH-066
**Стадия:** architecture
**Автор:** Architect
**Дата:** 2026-06-07
**Статус:** Accepted

> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на
> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`).
> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`,
> риски — `10-tech-risks.md`.

---

## 1. Контекст

Plane-доска оркестратора семантически перегружена: `In Progress` одновременно
означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат
из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного
перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`;
ORCH-066 завершает наведение порядка по утверждённой Owner модели.

**Жёсткое разделение двух слоёв (инвариант проекта):**

| Слой | Что | Источник | ORCH-066 |
|------|-----|----------|----------|
| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** |
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** |

Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только
из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена
Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих
триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами).

Целевая модель Owner:

```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация).
Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл
деплоя/деградация), **Cancelled** (человек отменил задачу).

---

## 2. Решение

### 2.1. Реестр логических статусов (`src/plane_sync.py`)

Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из
Plane API):

| Логический ключ | Plane name | Назначение |
|-----------------|-----------|------------|
| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. |
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. |
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |

Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`,
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`,
`testing`, `approved`, `rejected`. `Cancelled` уже присутствует.

### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12)

ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное
уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые
статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH
422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой
UUID.

**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:**

1. Резолв по имени из Plane API (как сейчас).
2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его
   **базового ключа из этого же проекта**:

   ```python
   _STATE_ALIAS_FALLBACK = {
       "to_analyse":      "in_progress",
       "analysis":        "in_progress",
       "code_review":     "review",
       "awaiting_deploy": "in_review",
       "deploying":       "in_progress",
       "monitoring":      "done",
   }
   # после резолва по имени, ДО _DEFAULT_STATES.setdefault:
   for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
       if new_key not in resolved and resolved.get(base_key):
           resolved[new_key] = resolved[base_key]
   ```
3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где
   API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал
   запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового
   ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал
   `KeyError`.

**Эффект деградации:**

| Сценарий | Поведение |
|----------|-----------|
| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). |
| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. |
| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). |
| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. |

Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и
`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не
пробрасывается) — индикация деградирует, слой A не затрагивается.

### 2.3. Маппинг стадия → статус

- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`):
  `analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`).
  `deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без
  изменений.
- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis`
  (для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется).
- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`,
  `review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это
  алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют.

### 2.4. Новые хелперы `src/plane_sync.py`

Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`,
never-raise):

- `set_issue_analysis(work_item_id, project_id=None)`
- `set_issue_code_review(work_item_id, project_id=None)`
- `set_issue_awaiting_deploy(work_item_id, project_id=None)`
- `set_issue_deploying(work_item_id, project_id=None)`
- `set_issue_monitoring(work_item_id, project_id=None)`

`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не
кидает `KeyError`.

### 2.5. Точки простановки статуса (разводка)

| Файл:место | Сейчас | Должно стать | AC |
|------------|--------|--------------|----|
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 |
| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 |
| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 |
| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 |
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 |
| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 |
| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 |
| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 |
| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 |
| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 |

**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт
`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor.
Разводим по `post_deploy.post_deploy_applies(repo)`:

```python
if next_stage == "done" and work_item_id:
    if post_deploy.post_deploy_applies(repo):
        set_issue_monitoring(work_item_id)   # self: окно наблюдения, НЕ Done сразу
    else:
        set_issue_done(work_item_id)          # не-self: терминальный Done как сейчас
# арм монитора (существующий блок ~361) — без изменений
```
Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`.
При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем
монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему.

**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку —
**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер
(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над
контейнером.

**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только
на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action».
Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим
кодом.

### 2.6. Reconciler (`src/reconciler.py`)

- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в
  списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`)
  и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` →
  `handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение
  идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19.
- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями
  `awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro
  (КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в
  skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться
  F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда
  они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы):

  ```python
  base_working = {states.get(k) for k in (
      "backlog","todo","in_progress","in_review","review",
      "architecture","development","testing","approved","rejected","done")}
  extra_waits = {states.get("awaiting_deploy"),
                 states.get("deploying"),
                 states.get("monitoring")} - base_working - {None}
  skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
  return cur in skip_set
  ```
  Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator
  (отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до
  «human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот
  networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status`
  red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия
  `done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner.

### 2.7. Без kill-switch

Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится
**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2)
держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это
проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает
флаг как опциональный — сознательно отказываемся.)

---

## 3. Затронутые модули (карта изменений)

| Модуль | Изменение |
|--------|-----------|
| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. |
| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. |
| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. |
| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. |
| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). |
| `src/config.py` | Без изменений (kill-switch не вводится). |

---

## 4. Инварианты (проверяемые, AC-21/AC-22)

- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт).
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2),
  merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений.
- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений.
- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений.
- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API).
- Все новые `set_issue_*` / резолв — never-raise.
- Не-self (enduro) терминальный `deploy → Done` — без регресса.

---

## 5. Последствия

**Плюсы**
- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены
  от индикации.
- `In Progress` разгружен: больше не «всё подряд».
- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию,
  ни конвейер.
- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting).
- Нет нового флага/таблицы → меньше движущихся частей.

**Минусы / ограничения**
- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до
  этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`).
- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен
  `reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск).
- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой
  extra; orchestrator → три статуса).

**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина
стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501)
до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта.

---

## 6. Альтернативы (отклонены)

- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной
  конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative
  alias-fallback (§2.2).
- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт
  естественный гейт раската (§2.7).
- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия +
  живой Plane API. Нарушило бы инвариант «без лишних зависимостей».
- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A);
  статусы — индикация, отделены от машины стадий.
