# R-08 — Иерархические волны стены М2 (единый дерево-указатель) — Design

**Дата:** 2026-06-09 · **Кодовая фраза эпика:** «роутер-наставник».
**Ветка:** `worktree-brainrepo` · commit-not-push.
**Статус:** дизайн утверждён владельцем (две ключевые развилки + понимание задачи). Следующий
шаг — adversarial-разбор (`audit-context-building → sharp-edges → variant-analysis`), затем
`writing-plans`. Это **самая рискованная M2-работа** эпика (меняет формат подписанного
плана + step-модель стены) → adversarial-разбор обязателен ДО плана.

---

## 1. Проблема и цель

**Сегодня** стена М2 (`enforce-supreme-gate`) пропускает мутирующее действие только если оно
совпадает со **следующим шагом** плоского запечатанного плана (`actionMatchesStep(step, action)`,
`step = nextStep(steps, ptr)`, на allow → `ptr+1`). Указатель — целое число, подписанное
(`signStepState`, R-19) и привязанное к `plan_id` (R-27).

**Трение:** если посреди исполнения крупный шаг нужно раздробить на под-шаги, сделать это «на
месте» нельзя — только вернуться в разговор и **перепечатать весь план** (closed-door C-5).

**Цель R-08:** разрешить плану быть **иерархическим** — крупный шаг несёт заранее утверждённые
`substeps`. Стена при достижении контейнера автоматически спускается в под-план, матчит листья,
выходит. Снимает трение без перепечати всего плана.

## 2. Инвариант безопасности (ядро)

**Под-шаги берутся ТОЛЬКО из подписанного плана** (решение владельца). Крупный шаг и все его
под-шаги пишутся и запечатываются **вместе одной HMAC-печатью** до начала работы. `freezePlan`
уже канонизирует весь массив шагов целиком (`canonicalJson`) — вложенные `substeps` попадают под
ту же подпись автоматически. **Нет ни одного пути кода, где текущий лист-шаг приходит не из
запечатанного дерева** → контроллер физически не может подсунуть неподписанный под-шаг.
Инвариант «действие = подписанный шаг» сохранён.

**Отвергнуто (DANGEROUS):** контроллер выдумывает под-шаги на ходу → стена бесполезна (любое
действие оправдывается выдуманным под-шагом). Этого пути в дизайне нет.

## 3. Модель контейнеров

Шаг с `substeps` — **чистый контейнер-группа**: сам действие НЕ матчит. Указатель автоматически
спускается в его под-план; матчат только **листья** (шаги без `substeps`). Закрытая дверь
(`ref`, C-5) и Δ7-проверка разрушительного применяются на **листе**. Контейнер может нести `ref`?
— нет (YAGNI): `ref`/`op`/`object` осмысленны только на листе; у контейнера значимо лишь
`substeps` (+ необязательная метка `n`/`title` для читаемости). Контейнер без `substeps` или с
пустым `substeps` = брак дерева → fail-CLOSED (block).

## 4. Единый дерево-указатель (выбор владельца)

Целочисленный указатель заменяется **деревом-указателем для всего** (`step-pointer.mjs`: стек
`[{index, length}]`). Плоский план = дерево глубины 1 → указатель ведёт себя байт-в-байт как
целочисленный (корневой уровень, advance = +1).

**Компоненты:**

### 4.1 `step-pointer.mjs` (есть createPointer/advance/enterSubPlan/exitSubPlan/isDone)
- `+ serializePointer(ptr)` / `deserializePointer(state)` — стек ↔ компактная форма для подписи.
- `+ advanceOverTree(steps, ptr)` — **детерминированный** один ход по дереву: на листе → `advance`;
  если следующая позиция — контейнер → `enterSubPlan(length=substeps.length)`; если уровень
  исчерпан → `exitSubPlan` и повтор на родителе. Структура хода берётся ИЗ дерева — контроллер
  на неё не влияет. Защита от бесконечного спуска/подъёма: жёсткий предел глубины (напр. 8) +
  предел итераций; превышение → throw (fail-CLOSED в обёртке стены).

### 4.2 `plan-lock.mjs`
- `withCriterionIds` рекурсит в `substeps` → `criterion_id` запечатан на всех уровнях.
- `+ treeLeaves(steps)` — **все** листья дерева depth-first (общий помощник). Плоский план →
  `treeLeaves` = сами `steps` (идентичность). **Вход для V1/V2 ниже** (variant-analysis).
- `+ treeLeafAt(steps, ptr)` — **один** текущий лист по указателю (спуск через контейнеры по
  `currentPath`). Указатель не резолвится в лист (за концом / битая структура / попал в контейнер
  без возможности спуска) → `null`. **SE-2:** возвращает только узел БЕЗ `substeps` либо `null`,
  никогда контейнер.
- `freezePlan` / `verifyFrozenPlan` — по сути не меняются (печать уже покрывает дерево, I10);
  добавить только рекурсию `criterion_id`. `planId` уже = sha256 канонизированного дерева.
- `+ validatePlanTree(steps)` — структурная валидация ДО доверия: `substeps` либо отсутствует,
  либо **непустой** массив (SE-4: пустой контейнер → block); нет контейнера без листьев; глубина
  ≤ предела (напр. 8); **контейнер НЕ несёт `op`/`object`/`ref`** (SE-2). Невалидно → стена block
  (fail-CLOSED). (Чистая, тестируемая.)
- **V1 (variant-analysis):** `sealedCriterionIds(frozenPlan)` переходит с `.steps.map` на
  `treeLeaves(frozenPlan.steps).map(s => s.criterion_id)` — иначе на дереве пропускает
  критерии под-шагов → Гейт-2 `criteriaFromSealedPlan` (М4) ложно блокирует валидный план.
- **V2 (variant-analysis):** `reconcileJournalToPlan(journal, steps)` `.some` переходит на
  `treeLeaves(steps)` — иначе лист-действие не матчит контейнер → ложные сироты «обход стены» (М5).
  Плоский план → `treeLeaves`=steps → V1/V2 байт-в-байт без регрессии.

### 4.3 `enforce-supreme-gate.mjs`
- `signStepState`/`verifyStepState`/`resolveStepPtr` переводятся с целого `ptr` на
  **сериализованный стек** (по-прежнему подписан R-19 + привязан `plan_id` R-27). Подмена любого
  уровня стека / чужой план / битая подпись / легаси-целое → **сброс к корню** (как сегодня
  `resolveStepPtr` на mismatch). Корневой указатель = `createPointer({length: steps.length})`.
- `decide`/`decideMode`/`runGate`: берут лист через `treeLeafAt(frozenPlan.steps, ptr)` вместо
  `nextStep`; позиция = сериализованный `advanceOverTree(...)` вместо `ptr+1`. Лист `null` →
  block («план исчерпан / указатель не резолвится»). `validatePlanTree` неуспех → block.
- **SE-1 (Critical):** `decide`/`decideMode` возвращают явный флаг `advance: true` + payload
  `advanceTo` (любой формы); `runGate` гейтит журнал+`saveStep` по `r.advance === true`, **НЕ**
  `typeof r.advanceTo === 'number'` (иначе на стеке ветка тихо не сработает → мутация без
  журнальной пред-записи, пробой I7). Тип-агностичный guard.
- `actionMatchesStep(leaf, action)` — **не меняется** (матч листа идентичен сегодняшнему).
- Δ7 (разрушительное in-plan), закрытая дверь C-5 (`ref` листа), артефакт-версия, escape G-1 α —
  **не трогаются**, применяются на листе.

## 5. Обратная совместимость (нулевая регрессия — жёсткое требование)

- Плоский план (без `substeps`) обязан вести себя **идентично** сегодняшнему. Все существующие
  тесты `enforce-supreme-gate.test.mjs` / `plan-lock.test.mjs` / `step-pointer.test.mjs` остаются
  **GREEN** без правок (regression-guard в плане).
- Легаси-состояние указателя (старое целое в `plan-step-<sess>`) под новый/любой план →
  `resolveStepPtr` возвращает свежий корневой указатель (старая сессия стартует заново — то же
  поведение, что сегодня на plan_id-mismatch). Никакой миграции формата на диске не требуется.

## 6. Fail-CLOSED (постура как сейчас)

- Битое дерево (циклы, `substeps` не массив, превышение глубины, контейнер без листьев) → block.
- Указатель не резолвится в лист → block.
- Сетап-исключение → panic-ветка (G-1 α escape оценён, иначе block) — без изменений.
- Аварийный выход владельца (escape) и Δ7 — без изменений.

## 7. Adversarial-разбор (что обязаны прожарить следующие скилы ДО плана)

Список классов-дыр для `audit-context-building → sharp-edges → variant-analysis`:

1. **Неподписанный под-шаг через любой путь.** Доказать: ВСЕ пути `decide`/`decideMode`/`runGate`
   берут лист только из `frozenPlan.steps` (под печатью). Нет ветки, где лист/под-шаг приходит из
   события, env, файла указателя, или вычисляется контроллером.
2. **Подмена позиции указателя.** Сериализованный стек подписан; подмена уровня → сброс к корню.
   Проверить: нельзя «перескочить» вглубь к разрушительному листу мимо родительских проверок.
3. **`advanceOverTree` недетерминированность / влияние контроллера.** Ход полностью функция от
   (дерево, текущая позиция). Никаких входов от события. Доказать терминируемость (предел глубины
   + итераций), отсутствие зацикливания спуск↔подъём.
4. **Битое/враждебное дерево.** Цикл (`substeps` ссылается на предка — невозможно в JSON-дереве,
   но проверить копии/глубину), гигантская глубина (DoS), контейнер с `op/object` (попытка
   сделать контейнер матчабельным). Всё → fail-CLOSED.
5. **Регрессия плоского пути.** Доказать байт-в-байт идентичность для depth-1.
6. **Закрытая дверь / Δ7 / артефакт на листе.** Убедиться, что C-5/Δ7/artifact_id проверки
   срабатывают на листе так же, как сегодня на плоском шаге (не обойдены спуском).
7. **Легаси-состояние.** Старое целое `ptr` под дерево-план → сброс, не мис-резолв вглубь.

## 8. Тестирование (TDD, наблюдаемый RED до prod-правки)

`step-pointer`: serialize/deserialize round-trip · advanceOverTree спуск-в-контейнер ·
авто-подъём при исчерпании · +1 на листе · предел глубины→throw · depth-N обход целиком.
`plan-lock`: treeLeafAt (лист / контейнер-спуск / за концом→null) · withCriterionIds рекурсия ·
validatePlanTree (валидное / контейнер-без-листьев / substeps-не-массив / глубина) ·
freezePlan покрывает substeps подписью (правка под-шага ломает sig).
`enforce-supreme-gate`: **плоский план идентичен** (регрессия) · дерево: матч листа вглубь ·
контейнер-не-матчит-действие · подмена стека→сброс к корню · битое дерево→block · легаси-целое→
сброс · Δ7/ref/artifact на листе · escape не тронут.
Регрессия tools-only ≥ baseline (на момент старта плана).

## 8.1 Итог adversarial-разбора (audit-context → sharp-edges → variant-analysis)

- **11 инвариантов** (audit-context): несущие **I2** (лист только из sealed-дерева) + **I8**
  (стена выбирает ход, не контроллер). Полная карта — в разборе сессии.
- **7 sharp-edges hardening'ов** (встроены в §4): SE-1 явный `advance`-флаг / SE-2 `treeLeafAt`
  только лист + `validatePlanTree` отвергает контейнер с op/object/ref / SE-3 `resolveStepPtr`
  всё-не-подписанный-стек→корень / SE-4 пустой контейнер→block + предел глубины/итераций→throw /
  SE-5 canonicalJson-подпись точной формы + строгий deserialize / SE-6 depth-first без пропуска +
  snapshot-тест / SE-7 regression-guard.
- **Класс «целочисленный указатель» локализован** (variant-analysis): только
  `enforce-supreme-gate.mjs` (+тесты) — нет cross-module потребителя формата → формат стека
  меняем в одном модуле.
- **2 flat-варианта в scope** (V1 `sealedCriterionIds` / V2 `reconcileJournalToPlan`) закрыты
  через `treeLeaves` (§4.2). **1 future-вариант** V3 — см. §9.

## 9. Границы (жёсткое)

- Трогаем ТОЛЬКО `step-pointer.mjs` / `plan-lock.mjs` / `enforce-supreme-gate.mjs` (+ их тесты).
  (sealedCriterionIds/reconcileJournalToPlan — внутри plan-lock, в scope.)
- **V3 future-зависимость (НЕ код R-08):** `enforce-normative-content-rules.mjs:253`
  `sealedPlanCoversEdit` сейчас hardcoded `false` (инертно). При Ф8 owner-wiring coverage-проверка
  через `actionMatchesStep` обязана стать tree-aware (матч против `treeLeaves`). Зафиксировать в
  плане как явную зависимость Фазы 8, чтобы не потерять.
- Стену М2 **усиливаем под волны, не ослабляя**: инвариант §2 сохранён, fail-CLOSED §6 сохранён,
  пол М5 / судья М4 / escape М6 не трогаются.
- Живьём стена заработает только после Фазы 8 (рубильник владельца) — R-08 готовит возможность.
- commit-not-push; НЕ `requesting-code-review` (жёсткое правило сессии).
