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

76 KiB
Raw Permalink Blame History

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

Статус (актуализация 17.07, D39.6-гигиена): v3.1 = дизайн-оф-рекорд; реализация ОТЛОЖЕНА — промт-эры D30.9 (BACKEND_D152_SESSION_PROMPT.md) superseded арх-ресетом D39; этап Б (three-hash/ guard_hash/verdict-split/fast-path v2) в ledger-очереди ПОСЛЕ слоя 1 (нарезка, гейтится exp15) — resume-путь денежно-критичен, реализация всё-или-ничего. ⚠ Скоуп спеки = ТОЛЬКО content-addressed resume: tmctl export сюда НЕ входит и его READ-половина уже ПОСТРОЕНА отдельно (D39.5, internal/pipeline/export.go); annotation/override-половина экспорта — отдельный отложенный кусок. Исходный статус v3.1 (для истории):** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в backend/docs/ (зона бэкенда); при лендинге оркестратор сворачивает её в docs/architecture/03-implementation-notes.md новым разделом.

0-бис. v3.1-амендменты (D22.2 два обязательных + пре-имплементационные)

Ревью D22.2 сконструировало и ИСПОЛНИЛО stale-hit, который v3 пропускал. Два обязательных амендмента до реализации:

  • (v3.1-a) Book.SourceLang/Book.TargetLangverdictSnapshotID. Языки — ВЕРДИКТ-входы, потребляемые ВНЕ рендера: classify использует TargetLang для cjk-echo-проверки (disposition.go:196 isCJKTarget), coverageCheck использует пару SourceLang/TargetLang для коридора len_ratio (chunkrun.go:56). editor.md НЕ рендерит {{target_lang}} (после флипа D30.1 билингв-редактор рендерит {{source_lang}}, но не target) → на editor-стадии content_hash их НЕ ловит, а вердикт (cjk/coverage) от них зависит. Сегодня они в ключе через brief_hash (book-поле), но v3 дропает brief_hashcontent_hash; значит нужен ЯВНЫЙ вердикт-фейт. Правило §4 переформулировано: судьбу обязано получить ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера (языки — в verdictSnapshotID; инцидентально они и в translator content_hash через рендер, но вердикт-зависимость — несущая). §4-строка добавлена.
  • (v3.1-b) cache_ttl-расщепление: Context.CacheTTL — advisory (НЕ в wireSnapshotID); prov.CacheTTL — в wireSnapshotID. Проверено кодом: clients.go:38 передаёт клиенту CacheTTL: prov.CacheTTL (провайдер-уровень, models.yaml, Anthropic-kind cache_control) — Context.CacheTTL (pipeline context.cache_ttl, «5m») НЕ доходит НИ ДО ОДНОГО клиента = wire-ИНЕРТЕН. v3 клал в wireSnapshotID инертный Context.CacheTTL и терял wire-влияющий prov.CacheTTL — задом наперёд. v3.1: Context.CacheTTL демотирован до advisory (response- нейтрален, reuse безопасен; НЕ пиним); prov.CacheTTL (пер-стадийно через резолвнутый провайдер) → wireSnapshotID + guard_hash (меняет cache_control на Anthropic-проводе). Anthropic снят со стека (models.yaml) → экспозиция нулевая, но механизм чист. §4-строки поправлены.

Пре-имплементационные (D22.2): (1) axis-sensitivity тест RequestHash — флип role/stage меняет хеш (сейчас мутация «удалить role» пережила бы сьют) → §12.9 тест-лист (г-тер); (2) абзац «паритетные не-цели» §8 (classify-Source для editor-строк; Retries/Escal.BudgetUSD вне хешей; транспорт-ретраи вне ключа); (3) line-refs освежены — ⚠ рефактор D23 расщепил runner.go на stagerun.go/chunkrun.go/escalation.go/bookrun.go/resume.go/status.go; исторические runner.go:NNN-ссылки ниже ИНДИКАТИВНЫ (по классу кода), актуальные локации — в §12-плане и по grep имени функции.

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 (classifyOutputclassify, 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-v1tm-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-v2tm-request-v3

RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning, jsonOnly, maxTokens, wireSnapshotID, msgs). Замена: snapshotIDwireSnapshotID; всё остальное как в 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 droppedcontent_hash Метаданные книги рендерятся в шаблон (render.go:108-121) → в msgs; per-chunk content_hash ловит правку брифа гранулярно. Правило (v3.1-a): ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера, обязано получить явную судьбу (content_hash его не ловит). Назначено: SourceLang/TargetLangverdictSnapshotID (cjk-gate/coverage-коридор — строка ниже, v3.1-a); YoPolicy/StyleAllowlistverdictSnapshotID (кормят стиль-флаггер); 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 droppedcontent_hash Правила чанкера дают ch.Textmsgs. Ре-чанкинг меняет границы/число чанков → 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 droppedcontent_hash Режим инъекции меняет наличие/содержимое инъекц-сообщения → msgs.
context_assembly.glossary_token_budget wire droppedcontent_hash Бюджет → eviction → инъектнутые строки → msgs (§10).
context_assembly.stm_depth wire droppedcontent_hash STM входит в msgs.
context_assembly.overlap_tokens wire droppedcontent_hash Overlap меняет ch.Textmsgs.
context_assembly.cache_ttl (Context.CacheTTL) advisory dropped (v3.1-b) Правка v3.1-b: wire-ИНЕРТЕНclients.go:38 передаёт клиенту prov.CacheTTL, а Context.CacheTTL (pipeline context.cache_ttl) НЕ доходит НИ ДО ОДНОГО клиента. Response-нейтрален, reuse безопасен → НЕ пиним (v3 клал его в wireSnapshotID ошибочно).
prov.CacheTTL (провайдер, Anthropic cache_control) wire (cache-control) wireSnapshotID + guard_hash (v3.1-b) Провайдер-уровневый TTL РЕАЛЬНО едет на провод (clients.go:38provider_anthropic.go cache_control). Пер-стадийно через резолвнутый провайдер стадии. Anthropic снят со стека → экспозиция нулевая, механизм чист.
Book.SourceLang / Book.TargetLang verdict verdictSnapshotID (v3.1-a) Правка v3.1-a. Языки — ВЕРДИКТ-входы вне рендера: TargetLang→cjk-echo (disposition.go:196), пара→len_ratio-коридор (chunkrun.go:56). editor.md НЕ рендерит {{target_lang}}content_hash editor-стадии их не ловит, а вердикт от них зависит. Инцидентально они и в translator content_hash (рендер {{source_lang}}/{{target_lang}}), но несущая — вердикт-зависимость. Уходят из ключа вместе с brief_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 droppedcontent_hash Человеческий лейбл; фактические байты промпта рендерятся в msgs (авторитет — рендер, не лейбл).
prompt_sha256 wire droppedcontent_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 droppedcontent_hash memoryNormVersion + матчер-часть memoryMatchVersion определяют, КАКИЕ записи инъектятся → инъекц-сообщение → msgs. §6.
memory_version (post-check часть) = memoryPostcheckVersion verdict verdictSnapshotID Алгоритм/пороги post-check — вердикт, пересчитывается вживую (translateChunk). §6. Правка D20.1(c): memoryNormVersion двусторонний — SOURCE-часть нормализует ключи матчера (wire, → memoryInjectVersioncontent_hash), а TARGET-часть нормализует dst-формы, по которым post-check ищет перевод в выводе (вердикт). Значит memoryNormVersion (target-сторона) входит ТАКЖЕ в memoryPostcheckVersionverdictSnapshotID — иначе правка 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_toguard_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 идентичен для обеих ролей. Оператор флипает роль стадии editortranslator (проба «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 ХИТИТ и отдаёт ХРАНИМУЮ диспозицию, посчитанную под СТАРОЙ ролью. Но флип editortranslator ВКЛЮЧАЕТ 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). Всё это сегодня → MemoryVersionsnapshotID → ключ. Правка → промах по книге. Ревью-инсайт: post-check пересчитывается вживую на каждом resume (translateChunk:774-788 над финальным prev) и в chunk_status НЕ хранится — значит memory_version можно убрать из ключа вызова целиком.

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

  • memoryInjectVersion (wire) = memoryNormVersion + матчер/селекция-часть memoryMatchVersion
    • инъектируемый контент approved-записей (src/dst/aliases — то, что рендерится в renderGlossaryBlock/renderEditorConstraintBlock). Определяет, ЧТО и КАК инъектится → инъекц-сообщение → msgscontent_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 (→ memoryInjectVersioncontent_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 материализуется из банка (пере-материализуется каждый прогон, seedGlossarymaterializeMemory) и гоняется вживую в 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.

Паритетные НЕ-цели (v3.1, D22.2 — что осознанно ВНЕ хешей). Формула суперсет-паритета не обязана хешировать входы, которые ни wire, ни вердикт: (а) classify-Source для editor-строкclassify принимает Source для soft-refusal-порога и cjk-echo, но на editor-выходе источник — это черновик, а не оригинал; это часть детерминированной классификации над content_hash-материалом, не отдельная ось ключа. (б) Retries.RegenerateBeforeEscalate и Escalation.BudgetUSDНЕ в снапшоте/ключе: они управляют СКОЛЬКО попыток/хопов делать, а не байтами какого-либо вызова; их правка меняет, какие attempt-оси материализуются, но каждый материализованный вызов уже уникально кейится своим attempt (ретрай) или моделью эскалации (хоп) — уже покрыто. Смена RegenerateBeforeEscalate не пере-оплачивает готовые чанки (их attempt=0-чекпоинты попадают), только разрешает/запрещает БУДУЩИЕ ретраи флагнутых. (в) Транспортные ретраи (timeouts в models.yaml, backoff) — вне ключа by design (тот же RequestHash через транспорт-ретраи и resume — F3-идемпотентность); их правка не инвалидирует ничего. Эти три — паритетные не-цели: их отсутствие в wireSnapshotID/guard_hash/verdictSnapshotID КОРРЕКТНО, а не пробел.

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_hashK+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: snapshotIDwireSnapshotID, бамп tm-request-v2tm-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: snapshotIDwireSnapshotID, бамп 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/ sanitizer/source_lang·target_lang — v3.1-a) → $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-контрпример); (г-тер, D22.2 axis-sensitivity) флип role/stage меняет RequestHash — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют); (д) dry-run порог: ниже — авто, выше — отказ без флага; (д-бис) editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½); (е) миграция: онгоинг-книга под v3 не пере-пинается на add-chapters; (ж, v3.1-b) Context.CacheTTL вне wireSnapshotID (правка «5m»→«1h» не re-bill-ит); prov.CacheTTL в wireSnapshotID (правка re-bill-ит Anthropic-стадию).

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), verdictSnapshotIDchunk_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-normverdictSnapshotID (пересчёт бесплатен); 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).