# D15.2 — Спека v2: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) **Статус:** **v2, сдан на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2** (D-лог §Ратификации 09.07 п.2: направление ратифицировано, реализация заперта до доработки трёх дыр). Зона: спека в `backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в `docs/architecture/03-implementation-notes.md` новым разделом. **Supersedes:** D15.2-content-addressed-resume-spec.md (v1). Направление v1 (расщепить `snapshotID` на wire-часть и вердикт-часть; ключ чекпоинта держать на wire-идентичности; вердикт-смену re-classify-ить на resume бесплатно) — **ратифицировано** и сохранено. v2 **закрывает три дизайн-дыры внешнего ревью**, которые заблокировали реализацию v1: - **(a)** fast-path-гард v1 (content_hash + wireSnapshotID + verdict_snapshot) **слабее текущего гарда**: не покрывает ПРЯМЫЕ wire-поля `RequestHash` (model / temperature / reasoning / maxTokens / escalate_to). Смена `temperature` 0.3→0.4 или `reasoning` low→none тихо отдала бы stale-текст — ре-открытие дефекта stale-serve F1. → §5, отдельная колонка `guard_hash`. - **(b)** расщепление `memory_version` (wire vs verdict) в v1 было «(Опц.)». **Сделано ОБЯЗАТЕЛЬНЫМ** (иначе главный сценарий онгоинга — append-термина/toggle гейта — не материализуется без переоплаты книги). → §6. - **(c)** loud-гейт согласия (`--resnapshot` fail-loud, Р6 «согласие на трату») content-addressed resume **демонтирует**; v1 просто удалял его. **Заменён pre-flight dry-run** «N чанков, ~$X» с порогом явного флага. → §7. **Гейтит:** онгоинг-режим (`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-v1, 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`. ### 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: поле `Adult` (`book.go:151,156`) и НЕрендеримые плейсхолдеры не в `msgs` — они wire-нейтральны by construction (не уходят на провод); если `Adult` когда-либо станет wire/verdict-влияющим — вернуть в `wireSnapshotID` (или `verdictSnapshotID`). | | `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. | | `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** | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. | **Примечание к `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`) сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе избыточны) с ключом. **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**. Уходит в **`verdictSnapshotID`** (§3.2), пересчитывается на 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). Этот диф **естественно ловит все эффекты второго порядка** (sticky, eviction, каскад на редактор — §10), потому что он пере-материализует `msgs` per chunk×stage, а не рассуждает о них аналитически. 2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd` (честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка `ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**». 3. **Порог + флаг.** Порог `rebill_consent_usd` (конфиг книги; дефолт, напр., `$0.50` ИЛИ ≤5% от `ProjectedBookUSD`, что меньше). Ниже порога — **проходим автоматически** (append-термина в 3 чанках стоит центы — без трения согласия). Выше порога — **отказ без явного флага `--accept-rebill`** (зеркалит старую семантику `--resnapshot`, но теперь content-scoped: гейт срабатывает только на РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change). Это сохраняет Р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`) снимается. - **`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, после ратификации v2) 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(content_hash, wireSnapshotID, model, temp, reasoning, jsonOnly, estimator/ratio/floor/policy)`, префикс `tm-guard-v1`. 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`-диф → «N чанков, ~$X»; порог `rebill_consent_usd` + флаг `--accept-rebill`. `tmctl status`: заменить булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф целей + проекция (§9). 9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl) → $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; (д) dry-run порог: ниже — авто, выше — отказ без флага; (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`. ## 13. Открытые вопросы оркестратору (решения, гейтящие ратификацию v2) 1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`** — это v2-уточнение над book-global `snapshotID` v1: правка `capability`/`model_extra` РЕДАКТОРА больше не пере-оплачивает ЧЕРНОВИК (§3.1). Выигрыш реальный, но добавляет поверхность реализации (хеш на стадию, а не на книгу). **Подтвердить: берём пер-стадийную гранулярность или держим book-global** (проще, но грубее)? 2. **Дефолт порога dry-run** `rebill_consent_usd` (§7) и **имя флага**. Предложение: порог = `min($0.50, 5% × ProjectedBookUSD)`; флаг `--accept-rebill` (отдельный от `--resnapshot`, который остаётся алиасом). **Нужно решение владельца/оркестратора** — это Р6-класс согласия на трату. 3. **Caveat `Adult`** (`book.go`): дроп `brief_hash` опирается на `content_hash`, но булев `Adult` НЕ рендерится ни в один шаблон → wire-нейтрален by construction (§4, строка `brief_hash`). Если `Adult` когда-либо должен инвалидировать работу (напр. смена канала/цензуры) — его надо ЯВНО вернуть в `wireSnapshotID` или `verdictSnapshotID`. **Подтвердить, что `Adult` остаётся wire/verdict-нейтральным**, либо назначить ему слой. --- **Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и избыточные входы. Фикс v2: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`), `verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана, гейт fast-path). Три дыры закрыты: **(a)** `guard_hash` покрывает прямые wire-поля, которых fast-path v1 терял (temp/reasoning/maxTokens/escalate) → нет ре-открытия stale-serve; **(b)** `memory_version` расщеплён ОБЯЗАТЕЛЬНО — inject-часть уходит в `content_hash` (из ключа вон), post-check+gate+decl → в `verdictSnapshotID` (пересчёт бесплатен); **(c)** loud-гейт согласия заменён pre-flight dry-run «N чанков, ~$X» с порогом `--accept-rebill` (Р6 сохранён). Безопасность F1 усилена (guard_hash — сильнее прежнего гарда). Онгоинг-мина D15 снята: append re-bill-ит только реально затронутые чанки×стадии. Реализация мала и хирургична, но **остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2**.