docs(router-mentor): R-08 hierarchical waves design spec

This commit is contained in:
Дмитрий
2026-06-09 04:51:39 +03:00
parent 0e995970f9
commit 93fcb5e141
@@ -0,0 +1,139 @@
# 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` запечатан на всех уровнях.
- `+ treeLeafAt(steps, ptr)` — текущий лист по указателю (спуск через контейнеры по `currentPath`).
Указатель не резолвится в лист (за концом / битая структура) → `null`.
- `freezePlan` / `verifyFrozenPlan` — по сути не меняются (печать уже покрывает дерево); добавить
только рекурсию `criterion_id`. `planId` уже = sha256 канонизированного дерева.
- `+ validatePlanTree(steps)` — структурная валидация ДО доверия: `substeps` либо отсутствует,
либо непустой массив; нет контейнера без листьев; глубина ≤ предела; нет «контейнер с op/object,
претендующих на матч». Невалидно → стена block (fail-CLOSED). (Чистая, тестируемая.)
### 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`; `advanceTo` = сериализованный `advanceOverTree(...)` вместо `ptr+1`. Лист `null`
block («план исчерпан / указатель не резолвится»). `validatePlanTree` неуспех → block.
- `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 (на момент старта плана).
## 9. Границы (жёсткое)
- Трогаем ТОЛЬКО `step-pointer.mjs` / `plan-lock.mjs` / `enforce-supreme-gate.mjs` (+ их тесты).
- Стену М2 **усиливаем под волны, не ослабляя**: инвариант §2 сохранён, fail-CLOSED §6 сохранён,
пол М5 / судья М4 / escape М6 не трогаются.
- Живьём стена заработает только после Фазы 8 (рубильник владельца) — R-08 готовит возможность.
- commit-not-push; НЕ `requesting-code-review` (жёсткое правило сессии).