# Дизайн: подпись escape-гранта (M6 FIX-5, key-gated) — defense-in-depth

**Дата:** 2026-06-10 · **Кодовая фраза эпика:** «роутер-наставник» · **Статус:** РЕАЛИЗОВАНО 2026-06-10 (одобрено владельцем; 3 задачи TDD + гейт закрытия закрыты; регрессия tools-only 3478 passed + 2 skipped GREEN, +13 новых, 0 регрессий).
**Тип:** design-only. Кода нет. Реализация — отдельный заход по слову владельца.
**Цепочка дизайна (согласована владельцем):** `brainstorming` → черновик → **разбор #1 + разбор #2** (`sharp-edges` → `variant-analysis` → `systematic-debugging`, оба по написанной спеке) → spec self-review → ревью владельца. `writing-plans` — следующая фаза.

---

## 1. Цель и почему это defense-in-depth

Аварийная дверь владельца (escape) — разовый «пропуск» `{type:'floor_escape', action, ts}`, который среда ([enforce-askuser-answer-parser.mjs](../../../tools/enforce-askuser-answer-parser.mjs)) пишет в `~/.claude/runtime/askuser-decisions-<sess>.jsonl` при реальном AskUser-выборе владельца, содержащем токен `FLOOR-ESCAPE: <action>`. Потребители (пол М5 / стена М2 / гашение М6) читают пропуска через единственный читатель [escape-grant.mjs::loadFloorEscapes](../../../tools/escape-grant.mjs#L87) и матчат по `action`+`ts`+5-мин-окну.

**Угроза:** контроллер форжит запись `floor_escape` напрямую в runtime → фабрикует «одобрение владельца» на произвольное действие. **Сейчас закрыто content-floor'ом** (М5: `node -e fs.*` / PowerShell-запись в runtime hard-blocked, C-1 аудита). FIX-5 добавляет **подпись**: даже форжённая запись (если бы прошла мимо content-floor) без секретного ключа не несёт валидной подписи → отвергается.

**Статус deferral (аудит 2026-06-09 §M6):** FIX-5 был осознанно отложен — «целостность гранта держит защита записи в runtime, а не подпись; spec §6 не обязательно». Владелец расконсервировал его как belt-and-suspenders (как `judge_mode` seal-time guard 2026-06-09).

## 2. Решения владельца (брейнсторм 2026-06-10)

1. **Раскатка = key-gated (вариант А).** Reader требует подпись ТОЛЬКО когда ключ доступен; нет ключа → принимает неподписанный (как сегодня, content-floor — backstop). Writer подписывает, когда ключ есть. Энфорсмент авто-включается при провижининге ключа в Фазе 8. Не key-AND-flag (вариант Б отклонён — лишний рубильник + риск порядка), не always-required (В отклонён — сложно/рискованно для аварийной двери).
2. **Объём = только `floor_escape`.** `approve_git_operation` (та же файл-шина, другой потребитель `shell-content-rules::loadApprovedGitOps`) — НЕ трогаем; его подделка — отдельный пункт.

## 3. Архитектура

Подпись ставится у **писателя** (среда, при реальном AskUser-событии) и проверяется у **единственного читателя** — оба через `resolveReceiptKey()` (тот же keychain `router-mentor-receipts`, тот же OS-пользователь). Ключ контроллеру недоступен → подделать подпись он не может.

```
Владелец → AskUser-выбор с «FLOOR-ESCAPE: <action>»
   │  (PostToolUse) enforce-askuser-answer-parser.processEvent
   ▼
toFloorEscapeRecord → {type:'floor_escape', action, ts}
   │  key = resolveReceiptKey() ;  если key → signFloorEscapeRecord(rec, key) → +sig
   ▼
append в askuser-decisions-<sess>.jsonl   (подписанная, если ключ есть; иначе как сегодня)
   │
Потребители (пол/стена/гашение) → loadFloorEscapes(sess)
   │  key = resolveReceiptKey()
   │    есть key → оставить только verifyFloorEscapeRecord(rec,key)===true (неподписанные/битые отброшены)
   │    нет key  → оставить все (текущее поведение, content-floor backstop)
   ▼
findOpenGrant / escapeGrantOpen — без изменений (получают уже-отфильтрованные гранты)
```

**Единственная ВЫДАЮЩАЯ точка чтения** = `loadFloorEscapes`; проверка подписи в ней автоматически покрывает всех потребителей, которые ОТКРЫВАЮТ дверь (пол/стена/egress/read-страж/нормативный/verify/criterion/гашение — все читают через неё, см. разбор #1 VA-1). Доска (`guard-block-log::loadRecentEscapes`) — отдельный НЕ-выдающий читатель того же файла (только показывает; форж там не открывает дверь — §11.1 VA-1).

## 4. Компоненты (3 точки касания + 2 helper'а, зеркало APPROVAL)

| # | Файл | Что |
|---|---|---|
| C1 | [receipt-sign.mjs](../../../tools/receipt-sign.mjs) | +домен `FLOOR_ESCAPE: 'floor-escape'` в `RECEIPT_DOMAINS` (R-31, чтобы подпись floor-escape не принималась за approval/frozen-plan и наоборот) |
| C2 | [askuser-answer-parser.mjs](../../../tools/askuser-answer-parser.mjs) | +`signFloorEscapeRecord(record, key)` / `verifyFloorEscapeRecord(record, key)` — **зеркало** существующих `signApprovalRecord`/`verifyApprovalRecord` ([:211-218](../../../tools/askuser-answer-parser.mjs#L211)), но домен `FLOOR_ESCAPE`. Чистые. |
| C3 | [enforce-askuser-answer-parser.mjs](../../../tools/enforce-askuser-answer-parser.mjs)::`processEvent` | при записи `esc`: `key = keyImpl()` (default `resolveReceiptKey`, инъектируем); если `key` → `esc = signFloorEscapeRecord(esc, key)`. `approve_git_operation` (`rec`) НЕ подписываем (§2.2). Fail-open сохранён (подпись не должна ломать PostToolUse-наблюдаемость — ошибка резолва ключа → пишем неподписанным). |
| C4 | [escape-grant.mjs](../../../tools/escape-grant.mjs)::`loadFloorEscapes` | key-gated верификация: читать ПОЛНЫЕ floor_escape-записи `{type, action, ts, sig}`; `key = keyImpl()` (default `resolveReceiptKey`, инъектируем); `key` есть → оставить только `verifyFloorEscapeRecord(rec, key)===true`; нет ключа → оставить все; затем map→`{action, ts}` + 5-мин-окно (как сейчас). `loadRecords` (приватный, используется только здесь) при необходимости вернуть полную запись вместо stripped. |

## 5. Формы данных

- **Запись floor_escape (без ключа):** `{type:'floor_escape', action:'<canon>', ts:<ms>}` (как сегодня).
- **Запись floor_escape (с ключом):** `{type:'floor_escape', action, ts, sig:'<64hex>'}`. `sig = signPayload({type, action, ts}, key, 'floor-escape')` — подписанный payload = запись БЕЗ `sig` (ровно `{type, action, ts}`). `verifyFloorEscapeRecord` strip'ает `sig`, пересчитывает над остатком, `timingSafeEqual` (как `verifyReceipt`).

**Инвариант согласованности (SD-критично):** подписываемое == хранимое-минус-sig. Писатель подписывает РОВНО `{type, action, ts}` и хранит РОВНО `{type, action, ts, sig}` — никаких лишних полей (иначе `verifyReceipt` включит их в пересчёт → mismatch → настоящий грант отброшен). `canonicalJson` сортирует ключи → порядок не важен.

## 6. Поведение key-gated (таблица)

| Ключ у reader | Запись | Результат | Комментарий |
|---|---|---|---|
| нет | без sig | **принят** | текущее поведение; content-floor — backstop |
| нет | с sig | **принят** | sig игнорируется (нечем проверять); не хуже |
| есть | валидный sig | **принят** | настоящий пропуск (подписан писателем при наличии ключа) |
| есть | без sig | **отброшен** | форж без ключа не может подписать → защита |
| есть | битый/чужой sig | **отброшен** | подделка/чужой домен → защита |

Направление **fail-closed**: при ключе сомнение → дверь не открывается (отвергаем). Настоящие пропуска подписаны → не страдают.

## 7. Граничные случаи / угрозы (вход для разборов)

- **Рассинхрон writer/reader по ключу:** оба `resolveReceiptKey` (тот же keychain/OS-пользователь) → симметрично. Пограничный момент «ключ провижинен МЕЖДУ выдачей (unsigned) и чтением (key present) одного пропуска» → пропуск отброшен; но окно 5 мин + одноразовость → ничтожно. Зафиксировать как несущую симметрию.
- **Обходной читатель сырого файла:** проверить (variant), что floor_escape читает ТОЛЬКО `loadFloorEscapes` — иначе обходной читатель форж не отфильтрует. (`loadConsumed` читает ДРУГОЙ файл `floor-escape-consumed-*`; consume гасит, не выдаёт.)
- **Гашение (consumed):** подделка «гашения» лишь ЗАКРОЕТ свою же дверь (DoS на себя), не откроет чужую → подпись consumed вне scope (YAGNI).
- **Анти-инъекция:** `action` берётся из `FLOOR-ESCAPE: <...>` токена, нормализован `canonicalAction` у потребителя; подпись над ним; домен изолирует (R-31).
- **fail-open писателя:** ошибка резолва ключа в `processEvent` → пишем неподписанным (PostToolUse-наблюдаемость не ломаем); при ключе у reader такой пропуск отбросится (редкий баг, не дыра — дверь можно перевыдать).

## 8. Вне scope (YAGNI)
`approve_git_operation` подпись (§2.2) · подпись consumed-записей (DoS-на-себя) · отдельный флаг-рубильник (key-gated его заменяет) · ротация/версионирование ключа.

## 9. Стратегия тестирования (TDD-инварианты — для writing-plans)

- C1: `RECEIPT_DOMAINS.FLOOR_ESCAPE === 'floor-escape'`; подпись floor-escape НЕ проходит как approval/frozen-plan (доменная изоляция).
- C2: `signFloorEscapeRecord({type,action,ts}, key)` → `+sig` (64hex); `verifyFloorEscapeRecord` true на целой, false на подделке/без sig/без ключа/чужом домене.
- C3 (processEvent): ключ есть → записанный floor_escape несёт валидный sig; ключ null → без sig (как сегодня); `approve_git_operation` НЕ подписан (регрессия). Инъекция `keyImpl`/`runtimeDir`/`nowMs`.
- C4 (loadFloorEscapes): ключ есть + подписанный → возвращён; ключ есть + неподписанный/битый → отброшен; ключ null → все возвращены (backward-compat); затем окно/`{action,ts}` как раньше. Инъекция `keyImpl`.
- Поток: writer-подписал (ключ) → reader (ключ) принял; форж (unsigned, ключ) → reader отбросил; pre-key (нет ключа) обе стороны → как сегодня.
- Регрессия tools-only ≥ baseline (план уточнит число; текущий 3465+2skip).

## 10. Интеграция (anchors)
- Не трогаем: `findOpenGrant`/`escapeGrantOpen`/`canonicalAction` (получают уже-отфильтрованные гранты), потребителей (пол/стена/гашение — читают через `loadFloorEscapes`), `approve_git_operation`-путь.
- Правим: `receipt-sign` (домен), `askuser-answer-parser` (helpers), `enforce-askuser-answer-parser` (подпись на записи), `escape-grant` (key-gated verify на чтении).
- Переиспользуем: `signPayload`/`verifyReceipt` (receipt-sign), `resolveReceiptKey` (receipt-key-config), паттерн `signApprovalRecord`.

## 11. Открытые пункты для адверсариального разбора (разбор #1/#2)
- Точно ли `loadFloorEscapes` — единственный читатель floor_escape (обходной путь)?
- Рассинхрон writer/reader ключа: есть ли реальный сценарий, где reader видит ключ, а writer нет (или наоборот)?
- fail-closed направление: не клинит ли key-gated отбраковка легитимную дверь при штатной работе писателя?
- Доменная изоляция: не путается ли `floor-escape` с `approval` (обе из того же файла/парсера)?
- `loadRecords` рефактор (strip→full): не ломает ли другие вызовы (подтвердить, что вызыватель один)?
- Класс «текст контроллера ≠ факт»: не появляется ли путь, где грант принимается по строке контроллера, а не по реальному AskUser-событию + подписи.

## 11.1 Разбор #1 — sharp-edges → variant-analysis → systematic-debugging

| # | Severity | Находка | Закрытие в дизайне |
|---|---|---|---|
| **VA-1** | **Med** | **Второй читатель floor_escape:** `guard-block-log::loadRecentEscapes` (доска, собрана 2026-06-10) читает те же записи. `loadFloorEscapes` — НЕ единственный читатель. | Доска **не выдающая** (display-only): форж там лишь покажется на дашборде, дверь НЕ откроет (выдача — только через key-gated `loadFloorEscapes`). **Безопасность не затронута.** Остаточно: post-key доска покажет форж-unsigned как «escape» (вводит в заблуждение, не дыра). Доп. key-gated проверка доски — **вне scope (YAGNI)**, зафиксировано как осознанный остаток. |
| VA-2 | info | `loadRecords` (escape-grant) приватный, единственный вызыватель — `loadFloorEscapes`. | Рефактор strip→full безопасен (нет других вызывателей). `loadConsumed` читает ДРУГОЙ файл напрямую, не через `loadRecords`. |
| SE-1 | Low | **Coupling формы подписи:** `verifyReceipt` сверяет запись-минус-sig с подписанным payload; добавление поля в `toFloorEscapeRecord` без синхронной правки обеих сторон → mismatch. | `signFloorEscapeRecord` подписывает ВЕСЬ `record`, `verifyFloorEscapeRecord` верифицирует ВЕСЬ `record`-минус-sig → симметрично при любой форме (обе берут полную запись). Робастно к добавлению полей, **пока хранимое == подписанное+sig** (§5 инвариант). |
| SE-2 | Low | **Пустой ключ:** `resolveReceiptKey` может вернуть `''` vs `null`. `signPayload(_, '', _)` → `null` (falsy key); `verifyReceipt` с `''` → false. | «Ключ есть» определяется как **truthy** (`if (key) ...`) на ОБЕИХ сторонах: `''`/`null`/`undefined` → трактуются как «нет ключа» → принять все (consistent, не клинит дверь). |
| SD-1 | info (бонус) | **ts в подписи** предотвращает replay: подписан `{type, action, ts}`; форж не может скопировать `action` со свежим `ts` и переиспользовать старую подпись (sig сломается). | Подпись над `ts` — бонус-защита от ts-tampering replay сверх forge-защиты. |
| SD-2 | info | fail-open писателя (key resolve бросил) → unsigned; reader с ключом отбросит → дверь для ЭТОГО пропуска не открылась. | Редкий баг, не дыра — пропуск перевыдаётся. PostToolUse-наблюдаемость не ломаем (§7). |

## 11.2 Разбор #2 — независимый проход (новые классы)

Методика R7: абстрактная классификация (множественные читатели / форма подписи / рассинхрон ключа / cross-session / доменная путаница / соседний approve_git_operation), потом перечисление. **Новых структурных/HIGH-дыр сверх #1 нет.**

| # | Класс | Вывод |
|---|---|---|
| P2-1 | **Cross-session форж** | `loadFloorEscapes(sess)` читает только файл текущей сессии; форж должен таргетить текущий файл (content-floor защищает запись в runtime; подпись — второй слой). Не новый класс. |
| P2-2 | **Соседний `approve_git_operation`** | Та же файл-шина, но `loadApprovedGitOps` (shell-content-rules) фильтрует `type==='approve_git_operation'` → игнорирует floor_escape; добавление `sig` к floor_escape его НЕ трогает. git-approval подпись остаётся **вне scope** (§2.2/§8) — известный отдельный пункт, не регрессия. |
| P2-3 | **Доменная путаница** | `signApprovalRecord` (домен `APPROVAL`) и `signFloorEscapeRecord` (домен `FLOOR_ESCAPE`) — R-31 изолированы: подпись одного домена не проходит проверку другого. Тест на изоляцию (§9 C1). |
| P2-4 | **Класс «текст контроллера ≠ факт»** | Грант рождается ТОЛЬКО из реального AskUser-события (`enforce-askuser-answer-parser` PostToolUse на ответ владельца с токеном `FLOOR-ESCAPE:`), не из текста контроллера; подпись добавляет аутентичность. Контроллер не пишет канал (runtime protected + теперь подпись). Новой дыры нет. |

**Acceptable residual:** VA-1 (доска показывает форж post-key — display-only, не дыра) · git-approval подпись (§2.2) — оба осознанно вне scope, зафиксированы.

## 12. Статус и следующие шаги
- [x] Дизайн сошёлся (брейнсторм, решения владельца §2).
- [x] **Разбор #1** по спеке (`sharp-edges` → `variant-analysis` → `systematic-debugging`): VA-1 (Med, доска-читатель — display-only, безопасность не затронута) + VA-2/SE-1/SE-2/SD-1/SD-2 (info/Low) — §11.1, все закрыты.
- [x] **Разбор #2** по спеке (новые классы): P2-1..P2-4 — новых структурных дыр нет, §11.2.
- [x] Spec self-review (плейсхолдеров нет / §3↔§5↔§7↔§11 консистентны / scope сфокусирован / неоднозначностей нет; «ключ есть» = truthy зафиксировано).
- [x] **Ревью владельца** — одобрено (брейнсторм §2 + санкция на реализацию).
- [x] `writing-plans` → план реализации + **executing-plans** (3 задачи TDD + гейт закрытия; регрессия 3478+2skip GREEN, +13 новых). Одно одобренное отклонение от дословного кода: Task 3 reader — быстрый путь (нет floor_escape-записей → `[]` без резолва ключа; поведение §3/§6 идентично, keychain-subprocess не дёргается в массовом пустом случае).

**Прод-код НЕ затронут** этой спекой (design-only). Defense-in-depth; энфорсмент авто-включается при провижининге ключа (Фаза 8), до того поведение неизменно.
