textmachine/backend/docs/D15.2-content-addressed-resume-spec.md

455 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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», а «+TL»).
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**.