# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)

**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в
`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в
`docs/architecture/03-implementation-notes.md` новым разделом.

**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
(«направление и 90% деталей ратифицированы; реализация разблокируется после v3-правок»):

- **(a)** guard_hash **обязан нести `role` + имя стадии** — без них формула НЕ суперсет текущей
  защиты: конструируемый stale-VERDICT fast-path hit при флипе роли (клейм «строго сильнее» был
  ложен). → §5, контрпример + §3.3/§3.4 формула.
- **(b)** dry-run §7 нуждается в **аналитическом правиле editor-каскада**: любая стадия НИЖЕ
  re-bill-юнита того же чанка сама считается re-bill (иначе консент-проекция занижает ~2× на самом
  дорогом классе правок — temp/reasoning/model translator'а, чей новый вывод $0-dry-run
  материализовать не может). → §7 п.1-бис.
- **(c)** полевой маппинг §4 **доукомплектован**: `style_check_version`; слой YoPolicy/StyleAllowlist;
  target-часть `memoryNormVersion` (→ и в postcheck-версию); судьба `jobs.snapshot_id`; verdict-фолд
  конфига гейтов только при enabled. → §4, §6, §9.

**Ответы D20.2 вписаны в текст v3:** Q1 — пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`
**ПОДТВЕРЖДЕНА** (§3.1); Q2 — порог `min($0.50, 5%×ProjectedBookUSD)` + флаг `--accept-rebill[=usd]`
(опциональный потолок суммы) + fallback-флор при `ProjectedBookUSD=0` **ПРИНЯТЫ**, но фикс каскада
(b) — ПЕРВЫМ (§7); Q3 — `Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой не назначается: вместо хеша
— **load-time adult-линт** (реализован бэкенд-пакетом №3: `Pipeline.CheckAdultChannel`; §4, §13).

**v2-контекст (сохранён):** v2 закрыл три дыры внешнего ревью — (a) fast-path-гард слабее текущего →
колонка `guard_hash`; (b) расщепление `memory_version` сделано ОБЯЗАТЕЛЬНЫМ; (c) loud-гейт согласия
заменён pre-flight dry-run. v3 доводит (a) до суперсета (role+stage), (b) до полноты (target-norm в
postcheck), (c) — маппинг §4 доукомплектован и каскад в проекции аналитический.

**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9 / D15 п.2). Текущий
all-or-nothing resnapshot остаётся **приемлемым для статичной приёмочной книги Ф1 (D15.1 / D18 蛊真人)
— НЕ трогать до приёмки**; эта спека — для онгоинга (Ф1.5).

---

## 1. Проблема (верифицирована D15 / 07 §4.4)

`snapshotID` (`runner.go:263-431`) вшит в `RequestHash` как поле `snapshotID` (`render.go:198,212-214`,
length-prefixed хеш). Значит **любое** изменение снапшота — флип coverage-гейта, toggle
`postcheck_gate`, бамп `classifierVersion`, append approved-терминов через `memoryVersion`, правка
инертной ручки — после `--resnapshot` пере-оплачивает **всю книгу**, включая **байт-идентичные**
запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse
существует (`msgsContentHash`, `render.go:233-249`), но заперт: fast-path в `runStage`
(`runner.go:899`) доверяет строке `chunk_status` только при `cs.SnapshotID == snapID`, поэтому
через смену снапшота не срабатывает.

Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже
исправлены (пакет 09.07): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель — сделать это
правдой и через смену снапшота, без потери snapshot-гарантий и без ре-открытия stale-serve.

## 2. Ключевое наблюдение: снапшот смешивает ДВА класса входов + ТРЕТИЙ, избыточный

`snapshotID` сегодня сворачивает вместе три категории, хотя ключ вызова нуждается только в одной:

**(A) Wire-определяющие, УЖЕ покрытые иначе** (msgs/content_hash или прямые поля `RequestHash`).
`brief_hash`, `chunker_version`, `context_assembly` (инъекционные ручки), `memory_version`
(матчер+инъекция) — всё материализуется в `msgs` (метаданные книги через шаблон `render.go:108-121`,
нарезка → `ch.Text`, инъекция глоссария как ОТДЕЛЬНОЕ сообщение `render.go:174-190`) →
`msgsContentHash`. `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ПРЯМЫЕ поля
`RequestHash`. `estimator_version`, `max_tokens_policy`, `max_output_ratio`, `min_max_tokens` — лишь
ДЕРИВИРУЮТ `maxTokens`, а его фактическое значение уже прямое поле.

**(B) Wire-определяющие, НЕ покрытые иначе.** Резолвнутая `capability`, `model_extra`,
провайдер-оверрайды (local `temp`/`max_tokens`/`model`), эскалационные `escalate_to` +
capability/extra/оверрайды, `pipeline_core` (C0…C3 — механика сборки), `cache_ttl` (cache-control на
проводе, не в `content_hash`). Меняют ТЕЛО запроса, но не заходят в ключ никак, кроме снапшота — это
единственная wire-часть снапшота, которую надо сохранить в ключе.

**(C) Вердикт-определяющие** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса:
`classifier_version` (`classifyOutput` → `classify`, `runner.go:201-219`), `coverage`
(enabled+пороги+`coverageGateVersion`), `postcheck_gate` (on/off) и алгоритм/данные post-check
(`memoryPostcheckVersion` + decl инъектнутых записей при gate ON — `computeMemoryVersion`,
`memory.go:225-229`). Все они пересчитываются ВЖИВУЮ на resume и **в `chunk_status` не хранятся**
(классификатор/coverage — в `classifyOutput`; post-check — в `translateChunk` над финальным `prev`,
`runner.go:774-788`). Значит из ключа вызова их можно убрать целиком.

Сегодня (B) и (C) сидят в `RequestHash` → вердикт-only или book-global-wire правка промахивается по
ВСЕМ чекпоинтам и пере-оплачивает книгу. Это и есть дефект.

## 3. Дизайн: три хеша вместо одного снапшота-в-ключе

Расщепляем `snapshotID()` и вводим отдельный fast-path-гард. Появляются ТРИ независимых хеша, каждый
на своём слое:

### 3.1 `wireSnapshotID` — ПЕР-СТАДИЙНЫЙ, идёт в `RequestHash`
Хеш ТОЛЬКО категории (B) для КОНКРЕТНОЙ стадии: резолвнутая `capability`, `model_extra`,
провайдер-оверрайды (`ProviderTemp`/`ProviderMaxTok`/`ProviderModel`), эскалационные
`EscalateTo`+`EscalateCapability`+`EscalateExtra`+`EscalateProvider*`, `pipeline_core`, `cache_ttl`.
Префикс версии `tm-wire-v1`.

**Пер-стадийность — часть выигрыша гранулярности.** Сегодня `snapshotID` book-global: правка
`capability` стадии-2 (редактор) меняет `RequestHash` стадии-1 (черновик). В v2 `RequestHash`
стадии-1 несёт `wireSnapshotID` ТОЛЬКО стадии-1 → правка редактора не пере-оплачивает черновик
(каскад на редактор идёт честно через изменённый `prev`, §10). Book-global входы (`pipeline_core`,
`cache_ttl`) кладём в `wireSnapshotID` каждой стадии одинаковым значением.

Смена `wireSnapshotID` стадии → `RequestHash` этой стадии по всей книге меняется → честный re-bill
(эти входы реально меняют тело/ответ каждого вызова; v1 §3 это уже фиксировал).

### 3.2 `verdictSnapshotID` — book-global, идёт в `chunk_status.verdict_snapshot` (НЕ в `RequestHash`)
Хеш категории (C): `classifier_version`, coverage-конфиг+`coverageGateVersion`, `postcheck_gate`,
`memoryPostcheckVersion` + decl-контент инъектируемых записей при gate ON (§6). Префикс
`tm-verdict-v1`. В ключ вызова НЕ входит. Хранится в `chunk_status` как триггер бесплатного
re-classify на fast-path (§5, §8).

### 3.3 `guard_hash` — ПЕР-СТАДИЙНЫЙ, хранится в `chunk_status`, гейт fast-path (НЕ в `RequestHash`)
Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед
доверием строке (закрывает дыру (a), §5):
`guard_hash = H(tm-guard-v2, stageName, role, content_hash, wireSnapshotID, model, temperature,
reasoning, jsonOnly, estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion)`.
Прямые wire-поля (`model`/`temperature`/`reasoning`/`jsonOnly`) и ДЕРИВАЦИЯ `maxTokens`
(версии+ratio+floor; сам текст для деривации уже в `content_hash` через `msgs`) — то, чего `content_hash`
+ `wireSnapshotID` не покрывают. `escalate_to` и эскалационная wire уже внутри `wireSnapshotID`.

**Правка D20.1(a): `stageName` + `role` ОБЯЗАТЕЛЬНЫ, префикс `tm-guard-v1`→`tm-guard-v2`.** Оба входят
в per-стадийный компонент старого `snapshotID` (stageSnap: `Name`,`Role`,… — `runner.go:387`) И в
`RequestHash` (`f(…, stage, role, model,…)` — §3.4). Без них `guard_hash` НЕ суперсет ни старого
snapshot-гарда, ни `RequestHash`-осей `stage`/`role`, из-за чего фаст-пас может отдать stale-ВЕРДИКТ
при флипе роли на wire-нейтральном чанке (контрпример — §5). `role` — не косметика: он определяет,
применяется ли coverage-гейт (только к translator-выходу, `runner.go:198-200`), т.е. РЕЗОЛВНУТЫЙ
вердикт. `stageName` — ключ строки `chunk_status` (PK по имени) и дизамбигуатор при book-global дифе
проекции §7. С правкой guard_hash строго ⊇ (per-стадийный snapshot-компонент ∪ прямые wire-поля).

### 3.4 Итоговый `RequestHash` (ключ чекпоинта), бамп `tm-request-v2`→`tm-request-v3`
`RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning,
jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wireSnapshotID`; всё остальное
как в `render.go:198`. Вердикт-версии из ключа **уходят**. Прямые wire-поля остаются прямыми (были и
есть) — это и есть покрытие, которого не хватало fast-path-у.

Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) сохраняется как
`H(все wireSnapshotID стадий, verdictSnapshotID)`, но перестаёт быть частью ключа вызова и
демотируется до advisory (§9).

## 4. Полный полевой маппинг снапшота (обязательно, ревью п.2)

Каждое поле, сегодня сворачиваемое в `snapshotID()` (`runner.go:263-431`) + `memoryVersion`
(`memory.go:232-259`) + coverage/classifier/postcheck, классифицировано:
**wire** (влияет на байты запроса) / **verdict** (влияет на классификацию) / **dropped-as-redundant**
(уже покрыто иначе). Колонка «Куда в v2»: `content_hash` (msgs), прямое поле `RequestHash`,
`wireSnapshotID`, `verdictSnapshotID`, `guard_hash`, либо dropped.

| Поле (сегодня) | Класс | Куда в v2 | Почему |
|---|---|---|---|
| `brief_hash` | wire | **dropped** → `content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Caveat закрыт v3:** НЕрендеримые брифа-поля не покрываются `content_hash` — их судьбы назначены явно: `YoPolicy`/`StyleAllowlist` → `verdictSnapshotID` (строки выше, кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. |
| `chunker_version` | wire | **dropped** → `content_hash` | Правила чанкера дают `ch.Text` → `msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. |
| `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). |
| `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. |
| `max_output_ratio` | wire (деривация) | **guard_hash** | Множитель `maxTokens`. То же. |
| `min_max_tokens` | wire (деривация) | **guard_hash** | Пол `maxTokens`. То же. |
| `context_assembly.glossary_injection` | wire | **dropped** → `content_hash` | Режим инъекции меняет наличие/содержимое инъекц-сообщения → `msgs`. |
| `context_assembly.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. |
| `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `msgs`. |
| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash` (в `msgs` только `Role`+`Content`; `msgsContentHash` опускает `CacheBoundary`/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на `content_hash` не рушится (см. примечание ниже таблицы). |
| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
| `jsonOnly` | wire | прямое поле `RequestHash` + `guard_hash` | Уже прямое (сейчас всегда `false`); держим в `guard_hash` для полноты. |
| `maxTokens` (фактический) | wire | прямое поле `RequestHash`; деривация → `guard_hash` | Значение — прямое поле; версии деривации — в `guard_hash` (выше). |
| `capability` (ResolveCapability) | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire-форма (budget-ключ, temp-режим, thinking-выключатель); не в `msgs`, не прямое поле. |
| `model_extra` (`ExtraBody`) | wire | **wireSnapshotID** + `guard_hash` | Меняет тело (GLM thinking-off и т.п.); не в `msgs`. |
| `provider_temp` (local) | wire | **wireSnapshotID** + `guard_hash` | Оверрайд поверх wire ПОСЛЕ хеша; не в `msgs`. |
| `provider_max_tok` (local) | wire | **wireSnapshotID** + `guard_hash` | То же. |
| `provider_model` (local) | wire | **wireSnapshotID** + `guard_hash` | Фактически отвечающий local-бэкенд (8b→14b); самый импактный local-оверрайд. |
| `escalate_to` | wire | **wireSnapshotID** + `guard_hash` | Фолбэк-модель: меняет wire эскал-подвызова И резолвнутую диспозицию (случился ли хоп). |
| `escalate_capability` | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire фолбэка. |
| `escalate_extra` | wire | **wireSnapshotID** + `guard_hash` | `extra_body` фолбэка. |
| `escalate_provider_temp/max_tok/model` | wire | **wireSnapshotID** + `guard_hash` | Провайдер-оверрайды фолбэка. |
| `pipeline_core` (C0…C3) | wire | **wireSnapshotID** + `guard_hash` | Механика сборки прохода (linear vs selection/fusion); консервативный catch-all wire-плана. |
| `prompt_version` (`st.PromptVersion`) | wire | **dropped** → `content_hash` | Человеческий лейбл; фактические байты промпта рендерятся в `msgs` (авторитет — рендер, не лейбл). |
| `prompt_sha256` | wire | **dropped** → `content_hash` | Шаблон рендерится в system/user сообщения → `msgs`. Рендер И ЕСТЬ `msgs`. |
| stage `name` | wire | **dropped из снапшота** → прямое поле `stage` + `content_hash` | `RequestHash` несёт `stage`; структура плана отражена в `msgs` через `prev`-цепочку. |
| stage `role` | wire | **dropped из снапшота** → прямое поле `role` + `content_hash` | `role` — прямое поле; он же выбирает инъекцию (translator/editor) → `msgs`. |
| `memory_version` (матчер+инъекция часть) = `memoryInjectVersion` | wire | **dropped** → `content_hash` | `memoryNormVersion` + матчер-часть `memoryMatchVersion` определяют, КАКИЕ записи инъектятся → инъекц-сообщение → `msgs`. §6. |
| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. **Правка D20.1(c): `memoryNormVersion` двусторонний — SOURCE-часть нормализует ключи матчера (wire, → `memoryInjectVersion` → `content_hash`), а TARGET-часть нормализует dst-формы, по которым post-check ищет перевод в выводе (вердикт). Значит `memoryNormVersion` (target-сторона) входит ТАКЖЕ в `memoryPostcheckVersion` → `verdictSnapshotID`** — иначе правка target-нормализации сменила бы post-check вердикт, но не отразилась бы в `verdict_snapshot` → stale-вердикт на resume. §6. |
| `postcheck_gate` (top-level + `gate:` в memory) | verdict | **verdictSnapshotID** | Toggle меняет резолвнутую диспозицию; пересчёт бесплатен. Сегодня двойной фолд (top-level `PostcheckGate` + `gate:bool` в `computeMemoryVersion`) → в v2 один раз. §6. |
| decl-контент инъектнутых записей | verdict (при gate ON) | **verdictSnapshotID** | Decl НЕ инъектится в `msgs`, но при gate ON влияет на post-check вердикт (`memory.go:225-229`). §6. |
| `classifier_version` | verdict | **verdictSnapshotID** | Пороги/порядок `classify`; пересчёт `classifyOutput` бесплатен на checkpoint-hit. |
| `coverage` (enabled+len_ratio+sent_cov_min+min_chunk_chars+`coverageGateVersion`) | verdict | **verdictSnapshotID** (ТОЛЬКО при `enabled`) | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. **Правка D20.1(c): фолдить в `verdictSnapshotID` ТОЛЬКО когда гейт enabled** — зеркаля дисциплину `coverageSnapshot()` (`runner.go:181-190`: `{enabled:false}` когда off, полный конфиг+версия когда on). Иначе твик порогов ВЫКЛЮЧЕННОГО гейта форсировал бы re-verdict/bust fast-path на всей книге, хотя off-гейт вердиктов не выносит. |
| `style_check_version` (`cheapGateVersion`, cheapgates.go) | verdict | **verdictSnapshotID** | Правка D20.1(c). Версионирует 4 дешёвых стиль-флаггера (тире-диалоги/ёфикатор/транслит-междометия/万-億). Они — наблюдаемость (пост-check `runCheapGates`, пересчёт вживую, в `chunk_status` НЕ хранятся, как classifier/coverage), но правка правила сдвигает записанные счётчики стиль-флагов → это ВЕРДИКТ-версия. Из wire-ключа УБИРАЕТСЯ (провод не трогает), кладётся в `verdictSnapshotID`; на resume re-classify стиля бесплатен. Сегодня фолдится как `StyleCheckVersion` в `snapshotID` (`runner.go:383`) — переезжает в `verdictSnapshotID`. |
| `YoPolicy` (book.yaml `yo_policy`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Ё-политика (auto/all-yo/all-e) кормит ёфикатор — вердикт-сторона, live-recomputed (класс post-check). ⚠ Сегодня едет через `BriefHash` (book-поле), но `brief_hash` в v2 задропан → `content_hash`; **YoPolicy НЕ рендерится ни в один шаблон** (это конфиг гейта, не текст модели), значит `content_hash` его НЕ ловит. Явно кладём в `verdictSnapshotID` (иначе смена ё-политики тихо не пере-выносит стиль-вердикт на resume). |
| `StyleAllowlist` (book.yaml `style_allowlist`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Как YoPolicy: пер-проектный allowlist транслит-междометий кормит стиль-флаггер (вердикт), НЕ рендерится в промпт → `content_hash` не ловит → явно в `verdictSnapshotID`. |

**Примечание к `cache_ttl`/`CacheBoundary`:** `RequestHash` включает `m.CacheBoundary`
(`render.go:219`), `msgsContentHash` — нет (`render.go:245-247`), а `guard_hash` строится из
`content_hash`. Следствие: смена cache-границы/TTL честно даёт свежий `RequestHash` (свежий вызов
если дошли до attempt-цикла), но fast-path на `content_hash`/`guard_hash` НЕ рушит reuse — это
корректно, т.к. cache-control response-нейтрален (валидный старый ответ переиспользуем). `cache_ttl`
в `wireSnapshotID` — консервативный пин для свежести ключа, не гард reuse.

## 5. HOLE (a): fast-path-гард обязан покрывать ВЕСЬ wire stage-план

**Дефект v1.** Fast-path (`resumeFromChunkStatus`, короткое замыкание в `runStage:897-901` ДО
`runAttempt`) доверяет строке `chunk_status`, когда совпали `content_hash` + `wireSnapshotID` +
`verdict_snapshot`. Но `content_hash` (= `msgsContentHash`) НЕ содержит прямых wire-полей
`model`/`temperature`/`reasoning`/`maxTokens`, а `wireSnapshotID` (§3.1) их тоже не содержит (они —
прямые поля `RequestHash`, не категория B). Значит смена `temperature` 0.3→0.4, `reasoning` low→none
или деривации `maxTokens` НЕ меняет ни `content_hash`, ни `wireSnapshotID`, ни `verdict_snapshot` →
v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённый на СТАРОМ wire. Это ре-открытие stale-serve F1.
Настоящий `RequestHash` эти поля включает, но fast-path замыкается ДО attempt-цикла, где `RequestHash`
считается.

**Взвешены два варианта:**

- **(1) Отдельная колонка `guard_hash`** (§3.3): хеш ВСЕГО wire stage-плана (`content_hash` +
  `wireSnapshotID` + `model`/`temp`/`reasoning`/`jsonOnly` + деривация `maxTokens` + `escalate_to`
  внутри `wireSnapshotID`). НЕ часть `RequestHash` — только гейт доверия fast-path. На fast-path
  пересчитываем `guard_hash` из ТЕКУЩЕГО конфига и сравниваем со строкой.
- **(2) Verify-by-recompute**: на fast-path пересчитать полные wire-параметры победившего attempt и
  сравнить, храня в строке всё нужное для пересчёта (модель/temp/reasoning/фактический maxTokens
  победителя, capability и т.д.).

**Рекомендация — вариант (1), `guard_hash`.** Проще и строго-сильнее:
- Один детерминированный хеш вместо хранения и по-полевого сравнения ~десятка параметров (вар. 2
  раздувает `chunk_status` и дублирует логику резолва).
- **Строго сильнее текущего гарда**: сегодня fast-path проверяет `snapshot_id`+`content_hash`;
  `guard_hash` покрывает `content_hash` (⊇) ПЛЮС весь wire-план, включая то, что `snapshot_id` терял
  при пер-стадийном расщеплении. Смена `temperature`/`reasoning`/`maxTokens`-деривации/`escalate_to`
  → `guard_hash` mismatch → fast-path проваливается в attempt-цикл → там `RequestHash` (с прямыми
  полями) промахивается → **честный свежий вызов** (это реально другая работа, не re-pay).
- **Разделение слоёв**: `RequestHash` — ключ оплаченного ответа; `guard_hash` — гард доверия
  материализованной строки-резолва. Держать их разными хешами (а не пихать всё в `RequestHash`)
  сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе
  избыточны) с ключом.

**Контрпример к «строго сильнее» БЕЗ role/stage (правка D20.1(a), обоснование правки §3.3).**
Возьмём чанк, у которого инъекция ПУСТА (глоссарий не сматчился / отключён): тогда
`renderGlossaryBlock` (translator) и `renderEditorConstraintBlock` (editor) дают ОДИНАКОВОЕ пустое
инъекц-сообщение → `msgs` идентичны → `content_hash` идентичен для обеих ролей. Оператор флипает
роль стадии `editor`→`translator` (проба «translator-редактуры»), НЕ трогая model/temperature/
reasoning/maxTokens. Тогда под guard_hash БЕЗ role:
- `content_hash` — тот же (пустая инъекция в обе стороны, вход `{{draft}}`/`{{text}}` тот же текст);
- `wireSnapshotID` — тот же (capability/model_extra/escalate не менялись);
- прямые wire-поля (`model`/`temp`/`reasoning`/`jsonOnly`) + деривация `maxTokens` — те же;
- → `guard_hash` СОВПАДАЕТ. И `verdict_snapshot` совпадает (role в него не входит).

Fast-path ХИТИТ и отдаёт ХРАНИМУЮ диспозицию, посчитанную под СТАРОЙ ролью. Но флип
`editor`→`translator` ВКЛЮЧАЕТ coverage-гейт (он бежит ТОЛЬКО на translator-выходе,
`classifyOutput`/`runner.go:198-200`): корректная диспозиция могла бы стать `excision_suspect`
(flagged), а хранимая — `ok` (editor, coverage не применялся). **Fast-path отдаёт stale `ok` — тихий
неверный вердикт, ре-открытие класса F1 на вердикт-оси.** С `role` внутри `guard_hash` флип роли
меняет `guard_hash` → fast-path проваливается в attempt-цикл → там `RequestHash` (который НЕСЁТ
`role`, §3.4 / `render.go:212`) **промахивается** мимо старого (role=editor) чекпоинта → **свежий
ОПЛАЧЕННЫЙ вызов**, ре-классифицированный под НОВОЙ ролью с применённым coverage → корректный вердикт.
⚠ **Это честный re-bill, НЕ $0-переклассификация:** т.к. `role` — прямое поле `RequestHash`, флип
роли расщепляет ключ чекпоинта (два role-конфига стадии не делят чекпоинт), поэтому dry-run §7 верно
покажет это как re-bill под порогом `--accept-rebill`, а не как бесплатную смену. Ключевой ВЫИГРЫШ
правки D20.1(a) — не $0, а **устранение stale-`ok` serve** (безопасность вердикт-оси); стоимость флипа
роли честна. (В общем случае `role` wire-влияющий — инъекция translator≠editor — так что держать его в
`RequestHash` консервативно верно; вырожденный пустой-инъекция кейс даёт лишь расточительный, но
корректно СОГЛАСУЕМЫЙ через §7 re-bill. Альтернатива «убрать `role` из `RequestHash` ради free
re-classify» конфлейтила бы два role-конфига одной стадии в один чекпоинт — не берём без явного
решения ратификации.) Тот же аргумент показывает, что `guard_hash` без `role` НЕ суперсет старого
snapshot-гарда (старый book-global `snapshot_id` нёс role/name всех стадий) — потому клейм «строго
сильнее» без правки ложен.

**Fast-path v2** (`runStage`): доверять строке ⟺
`cs.guard_hash == guard_hash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) &&
cs.disposition != skipped`. `content_hash` продолжаем хранить (сигнатура msgs — нужна dry-run’у §7 и
отчётности), но гейтом служит `guard_hash` (он его суперсет). При mismatch `guard_hash` —
проваливаемся в attempt-цикл (свежий вызов, если wire реально другой); при mismatch только
`verdict_snapshot` (guard совпал) — проваливаемся в `runAttempt`, где checkpoint ХИТИТ по
wire-идентичности → **re-classify бесплатно** → re-upsert строки с новым `verdict_snapshot` и
диспозицией (§8).

## 6. HOLE (b): расщепление `memory_version` — ОБЯЗАТЕЛЬНО

`memoryMatchVersion` (`memory.go:31-39`) сегодня версионирует ОБА: матчер/селекцию инъекции И
алгоритм post-check (слаг буквально несёт `…+postcheck-declaware`). `computeMemoryVersion`
(`memory.go:232-259`) сворачивает `memoryNormVersion` + `memoryMatchVersion` + `gate:bool` + контент
строк (approved-only при gate OFF; ВСЕ строки, включая `decl`+`status`, при gate ON). Всё это сегодня
→ `MemoryVersion` → `snapshotID` → ключ. Правка → промах по книге. Ревью-инсайт: **post-check
пересчитывается вживую на каждом resume (`translateChunk:774-788` над финальным `prev`) и в
`chunk_status` НЕ хранится — значит `memory_version` можно убрать из ключа вызова целиком.**

**Расщепление (обязательное, не опциональное):**

- **`memoryInjectVersion`** (wire) = `memoryNormVersion` + матчер/селекция-часть `memoryMatchVersion`
  + инъектируемый контент approved-записей (`src`/`dst`/aliases — то, что рендерится в
  `renderGlossaryBlock`/`renderEditorConstraintBlock`). Определяет, ЧТО и КАК инъектится →
  инъекц-сообщение → `msgs` → `content_hash`. **Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа
  НЕ является** — покрыт `content_hash` пер-чанково (это и даёт гранулярный re-bill append-а, §10).
- **`memoryPostcheckVersion`** (verdict) = алгоритм+пороги post-check + `gate:bool` (`postcheck_gate`)
  + **`decl`-контент ВСЕХ инъектируемых записей при gate ON** + **TARGET-часть `memoryNormVersion`
  (правка D20.1(c))**. Уходит в **`verdictSnapshotID`** (§3.2), пересчитывается на resume бесплатно.
  ⚠ **`memoryNormVersion` двусторонний:** SOURCE-нормализация ключей матчера — wire (→
  `memoryInjectVersion` → `content_hash`), но post-check ищет dst-формы в ВЫВОДЕ по TARGET-нормализации
  (`memory.go` пост-check матчит нормализованный dst), значит TARGET-часть `memoryNormVersion` —
  ВЕРДИКТ и ОБЯЗАНА входить в `memoryPostcheckVersion`. Если норм-версия единая (не расщепляется на
  src/target внутри слага) — держать `memoryNormVersion` В ОБЕИХ (`memoryInjectVersion` И
  `memoryPostcheckVersion`): дубль безопасен (inject-часть всё равно в `content_hash`), а пропуск
  target-стороны из вердикта дал бы stale-вердикт post-check на resume после правки нормализатора.

**Что именно ПОКИДАЕТ wire-ключ:** весь `memory_version`. Матчер/нормализация/инъект-контент — их
эффект целиком в `msgs`. **Что входит в `verdictSnapshotID`:**
1. `memoryPostcheckVersion` (алгоритм/пороги post-check);
2. `gate:bool` — судьба `postcheck_gate` **явно**: это ВЕРДИКТ-сторона (toggle меняет резолвнутую
   диспозицию `glossary_miss`, `runner.go:782-787`), кладём в `verdictSnapshotID`. Сегодняшний
   двойной фолд (top-level `PostcheckGate` в `snapshotID` + `gate:` в `computeMemoryVersion`)
   схлопывается в один источник — `verdictSnapshotID`;
3. **decl-контент** инъектируемых записей — судьба **явно**: `decl` НЕ инъектится в `msgs`, но при
   gate ON участвует в post-check вердикте (`memory.go:225-229` — «decl edit would silently flip a
   resumed chunk's disposition»). Значит `decl` — ВЕРДИКТ-сторона → в `verdictSnapshotID` (только при
   gate ON, зеркаля текущее условие `gateOn` в `computeMemoryVersion:238`). При gate OFF `decl`
   не влияет ни на wire, ни на вердикт → не входит никуда (dropped).

**Почему это корректно и бесплатно.** Post-check материализуется из банка (пере-материализуется
каждый прогон, `seedGlossary`→`materializeMemory`) и гоняется вживую в `translateChunk` над `prev`
независимо от того, пришёл `prev` из свежего вызова или из fast-path-резолва. Правка `decl`/порогов
→ новый банк → новый вердикт post-check → но **wire идентичен → $0**. `verdictSnapshotID` в
`chunk_status.verdict_snapshot` делает такую правку (а) видимой статусу (§9) и (б) триггером
пере-резолва per-stage fast-path (classifier/coverage тоже вердикт, но пересчитываются ТОЛЬКО в
`runAttempt`/`translateChunk`, не в `resumeFromChunkStatus` — потому `verdict_snapshot` busts
fast-path и заставляет пройти attempt-цикл, где всё пере-классифицируется бесплатно). Post-check-часть
в `verdict_snapshot` формально belt-and-suspenders (post-check и так live на chunk-level), но держит
`verdict_snapshot` полным отпечатком вердикт-конфига.

## 7. HOLE (c): замена loud-гейта согласия на pre-flight dry-run

**Что демонтируется.** Сегодня рассинхрон снапшота — fail-loud стоп (`runStage:865-869`;
`--resnapshot` = явное согласие, Р6-класс «согласие на трату»). Content-addressed resume демотирует
job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`guard_hash`-решение (§8, §9) — то
есть **удаляет этот гейт согласия**. Его нельзя просто выкинуть: Р6-класс согласия на трату должен
быть ЗАМЕНЁН, иначе тихая переоплата возвращается через заднюю дверь.

**Замена — pre-flight dry-run.** Перед `translate`, который сменил бы конфиг (и всегда в
онгоинг-режиме) прогоняем СУХОЙ проход (детерминированный, $0, без LLM):

1. **Проекция (источник — тот же пер-чанковый `content_hash`/`guard_hash`-диф, что и на fast-path).**
   Для каждого чанка×стадии материализуем ТЕКУЩИЕ `msgs` (`MessagesWithInjection` — дёшево, только
   строковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ
   `chunk_status`. Единица «будет re-bill», если её `guard_hash` отличается от хранимого И хранимая
   диспозиция была оплачена (ok/flagged). Диф ловит эффекты второго порядка, чьи `msgs`
   МАТЕРИАЛИЗУЕМЫ из хранимого банка (sticky, eviction — §10 пп.1-2): их инъекц-сообщение
   пересобирается из глоссария на $0.

1-бис. **Аналитическое правило editor-каскада (правка D20.1(b)) — ОБЯЗАТЕЛЬНО.** Диф §7.1 ловит
   каскад на редактор ТОЛЬКО когда меняется ИНЪЕКЦИЯ (append-термина, §10 п.3-i: T попадает и в
   editor-констрейнт-блок — материализуемо). Но для самого дорогого класса правок — **смена wire-параметра
   TRANSLATOR'а (temperature/reasoning/model)** — editor меняется через §10 п.3-ii (изменённый
   `{{draft}}`), а НОВЫЙ черновик $0-dry-run материализовать НЕ может (он появится только когда
   translator реально пере-вызовется). В проекции вход `{{draft}}` редактора остаётся СТАРЫМ
   (хранимым) → `content_hash`/`guard_hash` редактора выглядят НЕИЗМЕННЫМИ → редактор НЕ попадает в
   re-bill → **проекция занижает ~2× на 2-стадийном C1** (посчитан только translator, пропущен
   гарантированный каскад на editor). Фикс — правило поверх дифа: **любая стадия СТРОГО НИЖЕ re-bill-юнита
   ТОГО ЖЕ чанка сама считается re-bill, независимо от её собственного `guard_hash`-дифа** (её вход
   сменится, как только пере-выполнится upstream; downstream цена — из `chunk_status.cost_usd`
   downstream-строки, либо `EstimateUSD` над её текущими `msgs` с ПОДСТАВЛЕННЫМ проецируемым upstream).
   Правило чисто структурное (линейный C1: draft→edit), не требует материализации нового вывода.
   ⚠ Порядок реализации (D20.2-Q2): каскад-фикс §7.1-бис ЛОЖИТСЯ ПЕРЕД семантикой флага `--accept-rebill`
   — иначе порог согласия считается от заниженной вдвое суммы.
2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd`
   (честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка
   `ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**».
3. **Порог + флаг (форма ратифицирована D20.2-Q2).** Порог `rebill_consent_usd` (конфиг книги) с
   дефолтом **`min($0.50, 5% × ProjectedBookUSD)`**. **Fallback-флор при `ProjectedBookUSD=0`** (книга
   ещё не считалась / нет обработанных чанков → 5%-ветка даёт $0 и заблокировала бы даже центовый
   append): при `ProjectedBookUSD=0` порог = абсолютный флор `$0.50`. Ниже порога — **проходим
   автоматически** (append-термина в 3 чанках стоит центы — без трения согласия). Выше порога — **отказ
   без явного флага `--accept-rebill[=usd]`**: форма с ОПЦИОНАЛЬНЫМ потолком суммы (`--accept-rebill`
   принимает проецируемый re-bill целиком; `--accept-rebill=1.50` — только если проекция ≤ $1.50, иначе
   стоп: Р6 = согласие на КОНКРЕТНУЮ трату, не бланкетное). Гейт content-scoped: срабатывает только на
   РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change. `--resnapshot` остаётся
   алиасом-синонимом (семантика — «прими проецируемый re-bill», не «пере-пинай всё»).

Это сохраняет Р6-класс согласия на трату, убирая ложные тревоги (вердикт-only смена больше не просит
согласия — она $0). `--resnapshot` как флаг остаётся синонимом-алиасом для обратной совместимости, но
его семантика — «прими проецируемый re-bill», а не «пере-пинуй всё».

## 8. Resume: легальность reuse и безопасность re-classify (F1)

Reuse чекпоинта легален ⟺ **полная wire-идентичность**: `guard_hash` совпал (⊇ `content_hash` +
`wireSnapshotID` + прямые wire-поля + деривация `maxTokens`). Это ровно то, что даёт новый
`RequestHash` на attempt-оси: совпал `guard_hash` ⇒ совпал wire ⇒ `RequestHash` попадёт в оплаченный
ответ (то же основание, что у сегодняшнего same-snapshot resume). **Вердикт-различие
(`verdict_snapshot`) НИКОГДА не блокирует reuse перевода** — только заставляет пере-классифицировать.

Машинерия re-classify уже есть: на checkpoint-hit `runAttempt` (`runner.go:1094`) зовёт
`classifyOutput` над текстом чекпоинта, не доверяя старому вердикту; post-check — `translateChunk`
над `prev`. Правки:
- **Fast-path `resumeFromChunkStatus`**: гейт §5 (`guard_hash` + `verdict_snapshot`). При смене
  `verdict_snapshot` (guard совпал) — НЕ отдавать старую диспозицию: проваливаться в `runAttempt` →
  checkpoint хитит по wire → re-classify бесплатно → re-upsert `chunk_status` с новыми
  `verdict_snapshot`/диспозицией. Нужны колонки `chunk_status.guard_hash`, `chunk_status.verdict_snapshot`.
- **Вердикт-флип:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся бесплатно. `ok→flagged`
  (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на `attempt+1` или
  эскалация) — честно оплачивается (реально новая работа). Ретраи/эскалация уже на своих осях ключа
  (`attempt`, модель эскалации).

**Ось безопасности F1 (никакого тихого расходящегося re-pay).** Reuse — precondition на ТОЧНОЕ
совпадение wire-идентичности (`guard_hash` — сильнейший гард: точные байты промпта+инъекции ПЛЮС все
прямые wire-поля и деривация maxTokens, чего v1 не покрывал). Вердикт-смена меняет только post-settle
классификацию, НИКОГДА не запрос — потому re-classify матчнутого чекпоинта не может отдать
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`.

## 9. Судьба `chunk_status.snapshot_id` и status-ридеров

- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
  отчётность), но **перестаёт быть гейтом корректности**. Первичный resume-гейт — `guard_hash` +
  `verdict_snapshot` + `content_hash` (§5). Условие `cs.SnapshotID == snapID` (`runStage:899`)
  снимается.
- **`jobs.snapshot_id`** (правка D20.1(c), одним предложением) — следует за `chunk_status.snapshot_id`:
  **демотируется до advisory** (какой job-снапшот держит стадию главы), а book-global job-mismatch
  fail-loud (`runStage:865-869`/`904-908`: `job.SnapshotID != snapID` → стоп без `--resnapshot`)
  **заменяется** пер-чанковым `guard_hash`-решением + dry-run-проекцией §7; `UpdateJobSnapshot`
  оставляем для re-pin к текущему снапшоту (self-heal), FK `jobs.snapshot_id→snapshots` цел.
- **`SnapshotDrift`** (`status.go:53-55,283-284`: >1 snapshot_id среди строк) — **демотируется до
  информационного advisory**, не сигнала тревоги: при content-addressed resume сосуществование строк
  под разными job-снапшотами НОРМАЛЬНО (wire-идентичные чанки, резолвнутые в разное время). Больше не
  «loud».
- **`ConfigDrift`** (`status.go:56-61,295-299`: текущий конфиг рендерит иной снапшот, чем хранимый) —
  **заменяется/дополняется** проекцией dry-run (§7): вместо булева «снапшот отличается» показываем
  **«проецируемый re-bill: N чанков, ~$X»** из того же пер-чанкового `guard_hash`-дифа. Равенство
  снапшота остаётся грубой подсказкой; действенное число — пер-чанковая проекция (`currentSnapshotProjected`,
  `status.go:347`, расширяется до пер-чанкового дифа).
- **Redrive drift-guard** (`status.go:462-480`, добавлен этой сессией — рефузит redrive при
  `cs.SnapshotID != curSnap`): его резон-детр (drift → переоплата всей книги) **растворяется**
  расщеплением — drift больше не пере-оплачивает неизменные чанки. Guard **эволюционирует**: грубое
  сравнение snapshot-равенства заменяется на `guard_hash`-диф ТОЛЬКО целевых (сбрасываемых) чанков.
  Redrive и так пере-гоняет цели под текущим wire — это его цель; поэтому жёсткий рефуз снимается,
  заменяясь на pre-flight проекцию §7 (`--accept-rebill` при превышении порога) плюс сохранённый
  инвариант «DispOK-стадии не сбрасываются» (D-лог п.5в). Итог: redrive не рушит флаг-телеметрию
  громким отказом, а показывает проецируемую цену целей.

## 10. Blast-radius approved-term APPEND (со sticky + eviction + каскадом стадий)

**Первый порядок (главная история):** append approved-термина T re-bill-ит **только чанки, где T
ФАЕРИТСЯ**. T фаерится в чанке, если его нормализованный ключ встречается в `ch.Text` (для translator
`memory.go` Select) → T инъектится → инъекц-сообщение меняется → `content_hash` меняется → translator
этого чанка re-bill. Чанки, где T не фаерится → идентичная инъекция → идентичный `content_hash` → $0
(checkpoint-hit, re-classify free). Это FIRST-ORDER контракт.

**Второй порядок — три поправки (ловятся dry-run’ом §7 автоматически, т.к. он диффит `content_hash`
per chunk×stage, а не рассуждает аналитически):**

1. **Sticky-кэрри (`stickyDepth = 2`, `memory.go:70-73`).** T, фаернувший в чанке K, попадает в
   `activeIDs[K]` → `unionSticky` (`runner.go:577-588`) несёт его в K+1 и K+2 (в пределах ГЛАВЫ;
   сброс на границе главы, `runner.go:549-552`). В K+1/K+2 T инъектится через sticky, даже если его
   ключ там не встречается → их translator-инъекция меняется → `content_hash` → **K+1, K+2 тоже
   re-bill**. Радиус = {фаерящие чанки} ∪ {их следующие 2 внутри главы}.
2. **F2 eviction (`memory.go` бюджет `glossary_token_budget`).** Инъекция бюджет-лимитирована. В
   бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой
   второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и
   вытесняет строку L' — **меняя `msgs` K+1 сверх просто +T, хотя сам T там не фаерится**. Eviction
   НЕ расширяет множество чанков за пределы {фаерящие ∪ sticky}, но увеличивает дельту внутри них
   (не «+T», а «+T−L»).
3. **Каскад на редактор.** Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика
   (`editorInjection = renderEditorConstraintBlock(memSel.injected)`, `runner.go:717-719` — тот же
   `memSel`). Значит: (i) T-подтверждённый попадает и в констрейнт-блок редактора → `msgs` редактора
   меняются; (ii) черновик translator’а меняется (T применён) → вход `{{draft}}` редактора меняется
   → `content_hash` редактора меняется. **Любой re-billed translator-чанк каскадит на свою editor-стадию.**
   Первопорядковое «re-bill фаерящих чанков» надо читать как «re-bill фаерящих чанков × ОБЕ стадии».

**Итоговый радиус:** `{фаерящие T} ∪ {sticky +2/глава}` × `{translator, editor}`; всё прочее — $0
(checkpoint-hit → re-classify free). Онгоинг-выигрыш D15: подтверждение пачки терминов на границе
тома не пере-покупает готовые главы — только реально затронутые чанки×стадии, а pre-flight dry-run
(§7) показывает их число и цену ДО траты.

## 11. Sequencing миграции + одноразовый re-pin

Два изменения, оба ломающие ключ/схему:
- **Формула `RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v2`→`tm-request-v3`
  (`render.go:212`). Это смена ФОРМАТА ключа → **все существующие чекпоинты промахнутся ОДИН раз**.
- **Схема**: миграция **v8** (текущий максимум — **v7** после cheap-gate миграции этой сессии:
  `migrate.go` v7 = `ALTER TABLE retrieval_state ADD COLUMN n_style_flags/style_detail`):
  `ALTER TABLE chunk_status ADD COLUMN guard_hash TEXT NOT NULL DEFAULT '';`
  `ALTER TABLE chunk_status ADD COLUMN verdict_snapshot TEXT NOT NULL DEFAULT '';`
  (`snapshot_id`, `content_hash` — сохраняются). Существующие строки получают пустой `guard_hash` →
  fast-path у них всегда busts → падают в attempt-цикл, где `RequestHash` под v3 не совпадёт с v2 →
  свежий вызов (тот самый одноразовый re-pin). Строки самозалечиваются с новыми хешами при первом
  прогоне.

**Ограничение порядка (жёсткое).** Миграция+бамп ДОЛЖНЫ приземлиться **ДО первой онгоинг-книги
(`add-chapters`)**. Иначе: если книга уже несёт v2-чекпоинты, а миграция приходит В СЕРЕДИНЕ её
жизни, первый `add-chapters` после миграции **пере-оплатит ВЕСЬ бэклог** (все прежние главы
пере-пинятся под v3) — ровно та D15-мина, которую спека снимает. Поэтому:
- Статичную приёмочную книгу Ф1 (D15.1 / D18 蛊真人) либо завершаем под v2, либо принимаем её
  **одноразовый re-pin** (громкий, расхождений нет — D15.1) на границе Ф1.5.
- **Первая онгоинг-книга РОЖДАЕТСЯ под `tm-request-v3`** (начальные главы кейятся v3), чтобы
  последующие `add-chapters` находили v3-ключи и не пере-пинали бэклог.
- **Одноразовый re-pin осознан и задокументирован** (как v1→v2 сейчас): ни одна онгоинг-книга не
  должна пересекать миграцию в середине жизненного цикла.

## 12. Минимальный план реализации (Ф1.5, после ратификации v3)

1. **Расщепить `snapshotID()`** → `wireSnapshotID(stage)` (пер-стадийный, §3.1) + `verdictSnapshotID()`
   (book-global, §3.2). Полный `snapshotID` = их хеш (advisory).
2. **`RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v3` (§3.4).
3. **`guard_hash`** (§3.3): функция `guardHash(stageName, role, content_hash, wireSnapshotID, model,
   temp, reasoning, jsonOnly, estimator/ratio/floor/policy)`, префикс **`tm-guard-v2`** (правка
   D20.1(a): `stageName`+`role` ОБЯЗАТЕЛЬНЫ — иначе не суперсет, §3.3/§5-контрпример).
4. **Расщепить `memoryMatchVersion`/`computeMemoryVersion` (ОБЯЗАТЕЛЬНО, §6)** на `memoryInjectVersion`
   (→ `msgs`, из ключа убрать) и `memoryPostcheckVersion` (+`gate`+`decl`-при-gateON → `verdictSnapshotID`).
   Снять двойной фолд `postcheck_gate`.
5. **Миграция v8** (§11): колонки `guard_hash`, `verdict_snapshot`; писать/читать в
   `chunkstatus.go` (Upsert/Get/ForBook). `snapshot_id` оставить (advisory).
6. **Fast-path (`runStage`)**: гейт `cs.guard_hash == guardHash(тек.) && cs.verdict_snapshot ==
   verdictSnapshotID(тек.) && disposition != skipped`; иначе `runAttempt` (re-classify бесплатно, §8).
7. **Job-snapshot-mismatch**: демотировать fail-loud стоп (`runStage:865-869`) в пер-чанковое
   `guard_hash`-решение.
8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф + **правило каскада §7.1-бис (любая
   стадия ниже re-bill-юнита чанка = re-bill; ЛОЖИТСЯ ПЕРВЫМ)** → «N чанков, ~$X»; порог
   `rebill_consent_usd` = `min($0.50, 5%×ProjectedBookUSD)` c fallback-флором $0.50 при
   `ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
   булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
   целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**)
   → $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 +
   editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/
   capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать
   temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке
   (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под
   coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис)
   editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не
   только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е)
   миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.

## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)

Все три §13-вопроса v2 РЕШЕНЫ D20.2; сохранены здесь как запись контракта.

1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash` — ПОДТВЕРЖДЕНА** (D20.2-Q1). Живой
   довод оркестратора: вероятная смена редактора glm-5→grok-4.3 при book-global пере-оплатила бы весь
   translator-бэклог; поверхность мала — `snapshotID` уже итерирует стадии. Взято пер-стадийно (§3.1).
2. **Порог dry-run и флаг — ПРИНЯТЫ** (D20.2-Q2) с поправками: порог `min($0.50, 5%×ProjectedBookUSD)`,
   флаг `--accept-rebill[=usd]` (опциональный потолок суммы, отдельный от `--resnapshot`-алиаса),
   fallback-флор при `ProjectedBookUSD=0`. ⚠ Порядок: **фикс каскада §7.1-бис ПЕРВЫМ** (иначе порог
   считается от заниженной вдвое суммы). Форма вписана в §7 п.3 / §12 п.8.
3. **`Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой НЕ назначается** (D20.2-Q3). Вместо хеша —
   **load-time линт консистентности**: `adult:true` без единой `channel:adult`-стадии → громко. Линт
   **реализован бэкенд-пакетом №3** (`Pipeline.CheckAdultChannel`, вызывается в `NewRunner`; обратная
   сторона — `channel:adult` при `adult:false` — сознательно НЕ гейтится, Adult нейтрален до первого
   рантайм-потребителя, который обязан ЯВНО назначить слой). §4 (строка `brief_hash`).

---

**Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и
избыточные входы. Фикс: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
`verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана,
гейт fast-path). Три дыры v2 закрыты + **три правки v3 (D20.1)**: **(a)** `guard_hash` покрывает
прямые wire-поля (temp/reasoning/maxTokens/escalate) **И `stageName`+`role`** → строгий суперсет, нет
ре-открытия stale-serve ни на wire-, ни на вердикт-оси (контрпример с флипом роли — §5); **(b)**
`memory_version` расщеплён ОБЯЗАТЕЛЬНО — inject-часть в `content_hash`, post-check+gate+decl+**target-norm**
→ `verdictSnapshotID` (пересчёт бесплатен); dry-run §7 несёт **аналитическое правило editor-каскада**
(любая стадия ниже re-bill-юнита = re-bill, иначе консент занижен ~2×); **(c)** loud-гейт согласия
заменён pre-flight dry-run «N чанков, ~$X» с порогом `min($0.50, 5%×ProjectedBookUSD)` и флагом
`--accept-rebill[=usd]` (Р6 сохранён); маппинг §4 доукомплектован (`style_check_version`,
YoPolicy/StyleAllowlist, `jobs.snapshot_id`, coverage-фолд-при-enabled); `Adult` нейтрален — линт
`CheckAdultChannel` (уже в пакете №3). Безопасность F1 усилена. Онгоинг-мина D15 снята: append
re-bill-ит только реально затронутые чанки×стадии. Реализация мала и хирургична, но **остаётся
ЗАБЛОКИРОВАННОЙ до ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1).
