docs(router-mentor): R-08 hierarchical waves design spec
This commit is contained in:
@@ -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` (жёсткое правило сессии).
|
||||
Reference in New Issue
Block a user