Move both lived pack dumps into the backend archive slice: a quarter of the journal was evidence already ratified in note bodies

This commit is contained in:
heaven 2026-08-30 23:58:24 +03:00
parent 563ff2915e
commit 02a732900a
2 changed files with 495 additions and 487 deletions

View file

@ -281,493 +281,10 @@
> порча» — `D39.164` (`7d0c6f2`), пак «деньги» — `D39.170`, гейт схемы — `D39.173`.
### 📝 ЗАПИСКА-ПЛАН — пак «писатель книги» (промт `docs/archive/prompts/BACKEND_BOOKWRITER_PACK_SESSION_PROMPT_2026-08-30.md`, строка 236), сессия `textmachine-main-fc`, 30.08
**Что беру (зона `backend/`, HEAD на старте `67c05be`, дерево чисто).**
1. Прод-пакет `backend/internal/bookfile` (только stdlib — `archive/zip`, `encoding/xml`; без `import "testing"`):
`container.go` — OCF-контейнер (`mimetype` первым и `zip.Store`, `container.xml`, запись entry в порядке вызовов,
нулевые таймметки) — `chunktest/epub.go` переводится на него, его OPF-заглушка и XHTML-декой остаются тестовыми;
`epub.go` — EPUB 3 из нейтральной модели `Book{Identifier, Title, Language, Modified, Description,
Chapters[]{Title, Paragraphs[]}}`: `dc:identifier`/`dc:title`/`dc:language`/`dcterms:modified`, nav
(`properties="nav"`, ⛔ НЕ в spine), один XHTML на главу, `<h1>` = заголовок главы, `<p>` = абзац;
`txt.go` — чистый текст из той же модели (заголовок строкой, абзацы через пустую строку, главы через
пустую строку; ни баннеров, ни флагов, ни `gapMarker`).
2. Сборка из `BookExport``backend/internal/pipeline/bookbuild.go`: `Runner.BuildBook(formats, partial)`
`Export(false)` → дыры → отказ либо файл. Запись `writeFileAtomic` рядом с ProjectDB
(`<project_db>.book.<fmt>`, как `.manifest.json`/`.bank.json`), путь публикуется в
`StatusArtifacts.BookFiles` (`book_files`: map формат→путь, аддитивно) — приор промта принят: платформа
пути не выводит сама (17 §1). Существующий файл — перезапись: производный детерминированный артефакт, как
манифест; бэкап отказывает потому, что он точка восстановления, а файл книги пересобирается из стора.
3. Аддитивные поля `tm-export-v1` (форма не меняется, все `omitempty`): `ChunkExport.Heading` (`heading`) —
литерал `u.Members[0].Heading` в момент сборки записи (и у pending-юнита тоже, он из манифеста);
`BookExport.GhostUnits` (`ghost_units`: `[{chapter,chunk_idx}]`) — ключи ghost-строк, чтобы маркер стоял
в главе, а не только в счётчике; `BookExport.TextModified` (`text_modified`, `CCYY-MM-DDThh:mm:ssZ`) —
max(`chunk_status.updated_at`) по финальным строкам, вошедшим в файл (колонка `NOT NULL DEFAULT
datetime('now')`, `store/migrate.go:37`; в `ChunkStatus` дочитывается доп. полем). Это и есть
`dcterms:modified`: последнее изменение отгруженного текста — факт стора, не часов процесса.
4. Глагол `tmctl build --config b.yaml [--format epub,txt] [--out path] [--partial]` (по умолчанию оба формата;
`--out` — только при одном формате). Stdout — JSON-конверт `tm-build-v1` (пути, счётчики, `complete`).
Новый глагол, а не флаг `export`: `export` — чистое чтение в stdout, писать файлы из него — смешать
аудит-проекцию с артефактом; `manifest` — прецедент глагола-писателя сайдкара. `dispatchCommands` и
usage-строка расширяются — контрактное расширение того же рода, что `bank-apply` (тест
`TestParseNoArgsUsage` сам перечисляет такие расширения); чужим глаголам новые флаги ОТКАЗЫВАЮТСЯ
(дисциплина `--keys-file`/`--max-units`).
5. Отказ — новый `pipeline.RefusalBookIncomplete = "book_incomplete"` → exit **16** (полоса 1019; таблица
тотальна по `TestTheRefusalTableIsTotal`); сообщение перечисляет дыры `глава/юнит/причина`.
6. Читательские слова — ДАННЫЕ: `<langpack_root>/<target_lang>/reader.txt` (`key<TAB>value`, плейсхолдеры
`{chapter} {unit} {dropped} {holes} {total} {ghost}`), читает ОТДЕЛЬНЫЙ загрузчик `lang.LoadReaderWords`;
в `Pack.Version()` НЕ фолдится (пин тестом «наличие reader.txt не меняет Version()» + `current_snapshot`
minirun до/после). Ключ — язык ЦЕЛИ, не пара: слова читает читатель целевого языка, и они не зависят от
исходного. Поставляю `backend/configs/langpacks/ru/reader.txt`. Каталог `<tgt>/` загрузчик пака не
читает (`Load` — только `<src>/` и `<pair>/`, `langpack.go:215-330`), так что канал вне фолда по
построению, а не по соглашению.
**Политика §3.2 — по умолчанию ОТКАЗ, `--partial` — файл с пометкой; довод:**
- Дыры: pending · withheld (`Disposition!="pending" && FinalText==""`) · incomplete (`DroppedMembers>0` при
непустом тексте) — арифметика `render.go:573-587` выносится в общий хелпер `pipeline`, которым пользуется и
`renderExport` (вывод `--plaintext` побайтово прежний) · **ghost — тоже дыра**: при укрупнении нарезки
строка-лидер несёт текст ПРЕЖНЕГО, более короткого юнита, а остаток источника лежит в ghost-строках и в
файл не попадает (acceptance: 57 строк → 37 юнитов, 20 ghost при `pending 0` — ровно «молча полная»
книга). Позиция ghost внутри главы неизвестна → маркер в конце главы с числом потерянных строк.
- Default = отказ: D29.1(б) — reader-facing выдача fail-closed; дверь платформы получит `--partial` для
контрактного «may be exported, finished or not» (14 §createExport).
- `--partial`: пометка неполноты — первый абзац первого документа spine (то, что читатель видит, открыв
книгу) + `dc:description`; маркер — отдельный абзац на месте КАЖДОЙ дыры (pending/withheld — вместо текста
юнита; incomplete — перед текстом юнита, как `gapMarker`; ghost — в конце главы). Форма — из `reader.txt`;
без пака (acceptance, `runner.go:346`) — несловесная: `⚠ {chapter}.{unit}` у дыры, `⚠ {holes}/{total}` в
пометке (символ + числа, никакого словаря в Go). В `FinalText`/`export.go` маркер не попадает.
- drift — НЕ дыра: текст файла побайтово текст прогона (`export.go`, комментарий к `exportConfigDrift`);
попадает в JSON-конверт `build` и в WARN, в файл — нет («config drift» — операторский словарь).
Следствие §4 заявляю сразу: чистого бездрейфового файла на реальной книге на этом HEAD НЕТ — чистый
путь доказывается синтетикой, реальные книги — посадки на дыры.
- Оглавление и `<h1>` при `Heading==""` — номер главы цифрами (пустая ссылка nav невалидна; цифры —
единственный лейбл, который не решает открытый К-3 «какой лейбл видит читатель у главы без заголовка»;
словесный шаблон в данных — 1 строка после ратификации, вопрос владельцу в отчёте). При `Heading!=""`
срез известного префикса `heading+"\n\n"` из `FinalText` (`chunker.go:186-192`), `<h1>` = Heading.
- Абзац = непустая строка `FinalText`. Замерено на `minirun/export.json`: юнит 1/0 — 14 разделителей `\n\n`
и 7 одиночных `\n`, юнит 2/0 — 56 и 0: редактор чередует их без семантики, различать = выдумать
различие; строка — единица абзаца исходника-вебновеллы. Круг §4 сравнивает абзацы ингеста с абзацами,
которые положил писатель.
- Метаданные: `dc:title` = `config.Book.Title` как есть (на стенде `蛊真人` — ограничение в отчёт), `dc:language`
= `TargetLang`, `dc:identifier` = `urn:textmachine:book:<book_id>`, `dc:creator` нет (источника нет).
- 0 юнитов — отказ `sourceHasNoContent` (spine без элементов невалиден); 1 юнит — обычная книга.
**Что НЕ беру:** платформу и дверь `createExport`/`getExport`; лестницу D25.1 в коде и в `BookExport`;
строки 160/201 (`ApplyHeading` и три точки склейки не трогаю; модельные заголовки в прозе не извлекаю и не
правлю); `--resnapshot` и любые платные вызовы; `langpack_root` в копию acceptance; направление письма
(`dir`/`page-progression-direction`) — в `internal/lang` данных о направлении нет (`lang-script.txt`
только скрипты), RTL-цели в репо нет → вопрос в отчёт, не литерал; CSS/обложку; новые зависимости.
**Чем докажу:** герметично — `bookfile` (mimetype первым/stored, nav не в spine, детерминизм двух сборок,
экранирование, 0/1 глава), `pipeline` (круг `IngestEncoded` равенством абзацев на прогоне fake-провайдера;
pending через потолок; withheld через санитайзер; **ghost — усадка исходника после прогона (посадки в
тестах не было, строю)**; drift — бамп `prompt_version`; синтетический `BookExport` на incomplete), `cmd/tmctl`
(incomplete бинарём через `setupGapProject`; парс флагов; exit 16 через totality-тест), `lang` (парс
`reader.txt`; `Version()` не меняется от его наличия). На копиях книг в песочнице с переписанными путями:
minirun — оракул июля 14/14 по `final_text/snapshot_id/disposition`, `current_snapshot` до/после
`reader.txt`, `cmp` двух ПРОЦЕССОВ, epubcheck 5.3.0 `0 fatal / 0 error`, круг ингестом; acceptance — отказ с
перечнем и `--partial`-файл с несловесными маркерами; coldrun-a — отказ 14/14 pending;
`git -C books status --short -- gu-zhenren` до/после одинаков (снят до старта: 6 строк `D …-wal/-shm`).
`make battery` + `-race`; в середине — субагент-ревью готовой части против критериев; в конце —
адверсариальный проход по направлениям §4.
### ✅ ИТОГ — пак «писатель книги» (промт `docs/archive/prompts/BACKEND_BOOKWRITER_PACK_SESSION_PROMPT_2026-08-30.md`, строка 236), сессия `textmachine-main-fc`, 30.08
**Что построено (зона `backend/`, поверх `67c05be`; НЕ закоммичено — дерево на лендинг оркестратору; `git status`: 10 изменённых + 8 новых путей в `backend/`, плюс этот журнал).**
- `internal/bookfile/` (новый прод-пакет, только stdlib, без `testing`): `container.go` — OCF-контейнер (`mimetype` первым, `Store`, через `CreateRaw` — без data-descriptor, флаги `0x0000`; `container.xml`; entry в порядке вызовов; нулевые zip-таймметки); `epub.go` — EPUB 3 (`dc:identifier`+`unique-identifier`, `dc:title`, `dc:language`, `dcterms:modified`, nav `properties="nav"` ⛔ не в spine, XHTML на главу: `<h1>` + `<p>`); `txt.go` — чистый текст; `model.go` — модель `Book/Chapter`, `Blocks(i)` (единственное определение содержимого документа главы), `CleanText` (общая чистка XML-недопустимых символов для обоих форматов), экранирование.
- `internal/chunk/chunktest/epub.go` переведён на `bookfile.NewContainer`; тест-декои (OPF-заглушка, `<title>c</title><style>`) остались тестовыми; 9 импортёров зелёные без правки ассертов.
- `internal/lang/reader.go``LoadReaderWords(root, targetLang)`: `<langpack_root>/<target>/reader.txt`, читается ОТДЕЛЬНО от `Load``Pack.Version()` не фолдится по построению — `Load` читает только `<src>/` и `<pair>/`); `DefaultReaderWords()` — несловесная форма (символ + числа; тест пинит отсутствие букв). Данные: `configs/langpacks/ru/reader.txt` (7 ключей: `hole.{pending,withheld,incomplete,stale,ghost}`, `notice.{holes,ghost}`).
- `internal/pipeline/holes.go` — ОДИН предикат дыры `UnitHole`/`HoleCounts` (порядок = `renderExport`); `render.go` считает шапку `--plaintext` через него (вывод побайтово прежний).
- `internal/pipeline/export.go` — аддитивно (`omitempty`): `ChunkExport.Heading` (`heading` — литерал `u.Members[0].Heading`, у pending тоже), `BookExport.GhostUnits` (`ghost_units`, ключи ghost-строк, сортированы), `BookExport.TextModified` (`text_modified` — max `chunk_status.updated_at` по ФИНАЛЬНЫМ строкам, вошедшим в экспорт); `exportConfigDrift`: строка со стадией, которой нет в текущем конфиге, — drift (находка 10 ниже). `store/chunkstatus.go` дочитывает `updated_at` в `ChunkStatus.UpdatedAt` (колонка `NOT NULL DEFAULT datetime('now')`, `migrate.go:37`; миграции — только `ADD COLUMN`: у minirun `text_modified` = `2026-07-25T19:17:53Z`, время боевого прогона, не миграции 30.08).
- `internal/pipeline/bookbuild.go``Runner.BuildBook(BuildOptions{Formats, Out, Partial})`: `Export(false)``staleUnits``assembleBook` (чистая функция, возвращает список дыр) → отказ `RefusalBookIncomplete` (`refusal.go`, exit **16**) либо стейдж всех форматов + общий коммит в `<project_db>.book.<fmt>` (не запрошенные соседи удаляются — рядом с БД лежит ОДНА сборка); `StatusArtifacts.BookFiles` (`book_files`, map формат→абс. путь; в `status --json` и `manifest --json`). `--out` — только НОВЫЙ файл (существующий путь любого рода, несуществующий каталог → `config_invalid`, exit 10). Сбой между двумя `rename``write_incomplete` (exit 15) с перечнем landed/not landed.
- `cmd/tmctl`: глагол `build --config b.yaml [--format epub,txt] [--out path] [--partial]`; stdout — JSON `tm-build-v1` (`files`, `total/pending/withheld/incomplete/stale_units`, `stale_unknown`, `ghost_rows`, `config_drift`, `complete`, `text_modified`); чужим глаголам новые флаги отказываются, `build` отказывает `--plaintext/--pairs/--json`; usage-строка расширена (`invocation_test.go` — контрактное расширение по собственному комментарию теста «Every one is a deliberate contract extension»; это ЕДИНСТВЕННАЯ правка существующего теста). `backend/README.md` — строка `cmd/tmctl` дополнена `build`.
**Политика (§3.2) как реализована — ШЕСТЬ состояний, не пять:** дыры — pending · withheld (`Disposition!="pending" && FinalText==""`, плюс юнит, чей текст после чистки не даёт ни одного абзаца) · incomplete (`DroppedMembers>0`) · **stale** (финальная строка сделана под исходник, которого в книге уже нет: `content_hash` строки ≠ `cachedRenderedContentHashes` — тот же факт, по которому резюм перекупает юнит; считается ТОЛЬКО без дрейфа, под дрейфом `stale_unknown: true`, никогда не «нет») · **ghost** (при укрупнении нарезки строка-лидер несёт текст прежнего, более короткого юнита; остаток лежит в ghost-строках и в файл не попадает: acceptance 57→37, 20 строк при `pending 0`). По умолчанию — ОТКАЗ при любой дыре с перечнем `chapter N unit M: <вид> (<причина>)` и ghost-ключами (≤50 строк); `--partial` — файл с пометкой (первые абзацы первого документа spine + `dc:description`; в txt — после заголовка книги) и маркером на месте КАЖДОЙ дыры (pending/withheld/stale — вместо текста; incomplete — перед текстом; ghost — в конце главы, счётчиком). drift — НЕ дыра (текст файла = текст прогона): в JSON-конверте и WARN, в файле нет. Оглавление/`<h1>` при `Heading==""` — номер главы цифрами. Абзац — непустая строка `FinalText` (`\r\n`/`\r`/`\n`). `dc:title``book.yaml` как есть (на стенде `蛊真人`), `dc:language` = `target_lang` (форма тега BCP 47 проверяется, иначе `config_invalid`), `dc:identifier` = `urn:textmachine:book:<book_id>`, автора нет.
**Сверка состава против §3 (пункт → сделано → чем доказано; все команды — на копиях в песочнице `<SB>/stand`, бинарь `go build -o <SB>/stand/tmctl ./cmd/tmctl`).**
| §3 | Сделано | Доказательство |
|---|---|---|
| 3.1 EPUB + чистый txt из `BookExport`, ОДИН файл на формат, из `tmctl` | да: `tmctl build``<project_db>.book.epub` / `.book.txt` | `build-minirun.json`: 2 пути, `complete: true`; `java -jar epubcheck-5.3.0/epubcheck.jar <book>.epub``0 fatals / 0 errors / 0 warnings / 0 infos` на всех трёх EPUB (minirun, acceptance `--partial`, coldrun-a `--partial`) |
| 3.1 без аудит-баннеров/флагов/`gapMarker` в txt | да | `TestTXTShape`, `TestBuildBookRoundTrips…` (нет `=== CHAPTER`, `TEXT MISSING`, `pending`, `flagged`); `TestBuildRefusesTheGapAndPartialMarksIt` — реальный c-lite прогон бинарём: `⚠ 1.0 1` перед уцелевшим текстом, ни `INCOMPLETE`, ни `cjk_artifact` |
| 3.1 `--plaintext` не тронут | да | `cmp` вывода бинаря из `git archive HEAD` и нового: IDENTICAL на minirun (171932 б), acceptance (354821 б), coldrun-a (1077 б) |
| 3.1 основа — `chunktest/epub.go`, OCF в прод без `testing` | да | `TestEPUBFixtureMimetypeIsOCFConformant` и 9 импортёров зелёные без правки; `TestContainerIsOCFConformant` (флаги `0`, extra `0`, `mimetype` на фиксированных смещениях) |
| 3.1 детерминизм двумя ПРОЦЕССАМИ | да | `cmp first/<f> <copy>/<f>` — IDENTICAL для 6 файлов (две сборки в двух процессах); zip-таймметки `1980-00-00 00:00`; sha256 EPUB minirun `084ff0a9…`, совпал с независимой сборкой ревьюера |
| 3.2 пять (шесть) состояний, отказ по умолчанию / `--partial` | да | acceptance: `build` → exit 16 «pending=0 withheld=1 incomplete=1 stale=0 ghost_rows=20 of total_units=37 (stale: UNKNOWN …) · chapter 1 unit 0: withheld (cjk_artifact) · chapter 10 unit 0: incomplete — 1 member(s)… (length) · ghost rows: 1/1 1/3 …»; `--partial``⚠ 2/37`, `⚠ +20`, `⚠ 1.0`, `⚠ 10.0 1`, `⚠ 10 +1`…; coldrun-a: exit 16 (14 pending) / `--partial` со словесными маркерами из `reader.txt`; minirun (только drift): записан по умолчанию, `config_drift: true`, `stale_unknown: true`, в файле `⚠` нет; тесты `TestBuildBookRefusesAPending…`, `…WithheldUnitIsAHole`, `…GhostRowsAreAHole` (посадка построена: усадка исходника после прогона), `…StaleUnitsAreAHole`, `…DriftIsReportedNotMarked`, `…RemovedStageIsDrift`, `TestAssembleBookMarksEveryHoleKind` |
| 3.2 не в снапшот | да | `current_snapshot` minirun с `ru/reader.txt` и без — `bb0b3b2aaf0d…` оба раза (копия корня пака в песочнице); `TestReaderWordsDoNotMoveThePackVersion` |
| 3.2 книга без langpack — несловесно | да | acceptance (`langpack_root` не задан): `⚠ 1.0`, `⚠ 10.0 1`, `⚠ 2/37`; `TestReaderWordsAbsentIsTheNonVerbalForm` |
| 3.2 маркер не в `FinalText`/`export.go` | да | JSON HEAD vs new: `existing equal True`, добавлены только `text_modified`, `heading`, `ghost_units` |
| 3.3 `ApplyHeading` и три точки не тронуты; срез только известного префикса | да | `git diff HEAD -- backend/internal/chunk/chunker.go backend/internal/chunk/ingest.go backend/internal/pipeline/waverun.go` пуст; вызов `ApplyHeading` в `export.go` — контекстная строка; minirun: 10 заголовков срезаны, круг 402/402; `TestAssembleBookMarksEveryHoleKind` (упоминание заголовка в тексте не срезается) |
| 3.3 `Heading==""` → номер цифрами в nav и `<h1>` | да | acceptance nav `1``25`; minirun/coldrun-a — `Глава N` из `heading` |
| 3.3-бис пять требований EPUB 3; nav не в spine; `dc:language` из `TargetLang` | да | `TestEPUBCarriesTheFiveRequiredThingsAndKeepsNavOutOfTheSpine`; OPF minirun: `urn:textmachine:book:guzhenren-minirun`, `蛊真人`, `ru`, `2026-07-25T19:17:53Z`; epubcheck |
| 3.4 место/атомарность/конверт/глагол/коды | да | `status --json`/`manifest --json` копии minirun: `book_files: {epub, txt}`; `TestTheRefusalTableIsTotal`, `TestDispatchCommandsCoversTheSwitch`, `TestParseNoArgsUsage`; `TestBuildBookLeavesOneBuildBesideTheDatabase`; `TestBuildBookOutNamesANewFile` |
| 3.5 платформа не тронута; `tm-export-v1` аддитивно; пары — данные; без зависимостей | да | `git status` — только `backend/` и `docs/PROGRESS.md`; `go.mod` не менялся; греп читательских слов по новому Go-коду — только комментарии |
| §4 `make battery` (`-race`) | да, 2 настоящих прогона | `battery-1.log` (до правок ревью) и `battery-3.log` (после всех правок): `BATTERY_EXIT=0`, 18 пакетов, скипы — три прежних стендовых (`TestMinerFullBookParity`, `TestHelperEventsRun`, `TestHelperKillLoop`). ⚠ «Батарея №2» была ложным прогоном: `make` из корня репо (`No rule to make target`), мой `echo` замаскировал код — поймано по логу, перезапущено из `backend/` |
| §4 дифф `^func Test` | ни один не исчез | `git diff HEAD -- '*_test.go' \| grep '^-func Test'` пуст; `func Test` в `backend/`: 1017 → 1044 (+27) |
| §4 круг ингестом равенством | да | `tmroundtrip` (собран `go build -overlay`, файла в дереве нет): minirun 10/10 глав, 402 абзаца; acceptance `--partial` 25/25, 1568; coldrun-a `--partial` 10/10, 25 |
| §4 `git -C books status` до/после | см. obstacle | 6 строк `D …-wal/-shm` до; после — 4: у acceptance `-wal`/`-shm` ПОЯВИЛИСЬ (идентичны трекнутым; БД не изменена) |
| §4 хирургия конфигов в копиях | список | minirun/coldrun-a `book.yaml`: удалены `mined_delta:`/`mined_rejects:`; acceptance `pipeline-acceptance.yaml`: удалены `stm_depth: 2`, `overlap_tokens: 200`, оба `prompt:`; добавлен `acceptance/pairs/zh-ru.yaml` (копия minirun/pairs с `prompts_root` на `backend/prompts` этого дерева); все абсолютные пути переписаны на песочницу/этот ворктри, греп по копиям пуст; миграции v10/v7/v14 → v16, `backups/` — в песочнице |
| §4 промежуточное и финальное ревью субагентом | да, Fable 5 ×2 | ниже |
**Промежуточное ревью (Fable 5, исполнением) — 8 находок, все закрыты:** (1) MAJOR: `--format`-подмножество оставляло соседний формат прежней сборки рядом с БД при равноправном `book_files` → стейдж всех форматов + общий коммит, не запрошенные соседи удаляются, пин `TestBuildBookLeavesOneBuildBesideTheDatabase`; (2) `text_modified` считался по всем строкам всех стадий → только по финальным строкам экспорта (acceptance `20:41:47Z``20:40:00Z`, coldrun-a — эпоха: ничего не отгружено); (3) пустой `title` → exit 1 → `config_invalid` с именем ключа, то же для `target_lang` не в форме BCP 47; (4) `mimetype` с data-descriptor → `CreateRaw`; (5) control-символы срезались только в EPUB → `CleanText` один раз для обоих; (6) `--format epub,epub` → дедуп; (7) `backend/README.md` без `build` → дополнено; таблицы exit-кодов в `docs/` — пинг; (8) подтверждено верным: `UnitHole` = арифметика `renderExport`, `DispOK` с пустым текстом недостижим, нулевые таймметки, `reader.txt` вне `Load`.
**Финальный адверсариальный проход (Fable 5; overlay-тесты, свой бинарь, epubcheck на adversarial-книгах: краевые символы, RTL, 2000 глав) — 10 находок:**
1. MAJOR — правка исходника НА МЕСТЕ (ключи `(chapter, chunk_idx)` уцелели, текст под ними сменился) давала файл, побайтово равный прежнему, под `complete: true`. **Исправлено — шестое состояние `stale`** (см. политику; пин `TestBuildBookStaleUnitsAreAHole`: правка главы 2 → `chapter 2 unit 0: stale`, глава 1 не задета; после `translate` — снова whole). ⚠ Побочный факт: `projectRebill` сверяет content-hash ТОЛЬКО при сдвинутом снапшоте (bank-only ветка), при том же снапшоте строка идёт как «$0 resume» без сверки — проекция денег `status` слепа к правке исходника на месте, `translate` при этом перекупает (ревьюер воспроизвёл: «2 units re-bought»). Не моя строка — пинг.
2. MAJOR — `--out` перезаписывал что угодно, включая БД проекта («file is not a database») и исходник. **Исправлено:** `--out` только для НОВОГО файла; существующий путь / отсутствующий каталог → `config_invalid`; место рядом с БД по-прежнему перезаписывается. Пин `TestBuildBookOutNamesANewFile` (БД, исходник, чужой файл, несуществующий каталог; ни одного `.tmp-` после). Стенд: `--out <project_db>` → exit 10, БД цела.
3. MINOR — сбой на фазе коммита второго формата оставлял смешанную сборку и exit 1. **Исправлено:** `write_incomplete` (exit 15) с перечнем landed/not landed; комментарий файла говорит правду. Тестом не покрыто («не проверено»: сбой `rename` в только что записанном каталоге без инъекции не воспроизводится).
4. MINOR — юнит с непустым `FinalText`, дающий ноль абзацев (control-символы/`U+FFFF`), не был дырой. **Исправлено:** сборщик читает юнит по тому, что тот реально положил (ноль абзацев → withheld); счётчики отчёта — из списка дыр сборщика; `HoleCounts` остался ровно арифметикой `renderExport`.
5. MINOR — голый `\r`: txt нёс его, XML нормализует → форматы расходились. **Исправлено:** `\r\n`/`\r`/`\n` — конец строки.
6. MINOR — `--out` в несуществующий каталог → exit 1. **Исправлено:** `config_invalid`.
7. NIT — `build` молча принимал `--plaintext/--pairs/--json`. **Исправлено:** отказ.
8. NIT — `dc:title` с паддингом vs обрезанный txt. **Исправлено:** `TrimSpace` в модели и EPUB.
9. NIT — ghost-маркер по номеру главы СТАРОЙ нарезки может встать в соседнюю главу при усадке. **Не исправлено, задокументировано** (`assembleBook`): книга всё равно помечена неполной, ложна лишь координата; иного источника координаты у ghost-строки нет.
10. MINOR (унаследовано `Export`) — удаление стадии `edit` делало черновики «полной» книгой без дрейфа. **Исправлено в `exportConfigDrift`:** строка со стадией, которой нет в конфиге, — drift; под ним stale = unknown; пин `TestBuildBookRemovedStageIsDrift`. ⚠ `Status` (`checkWave`) несёт то же слепое пятно — пинг.
Подтверждено проходом как верное: все посадки A (withheld при `pending 0`, ghost-only, исчезнувшая глава, все юниты withheld, 0 юнитов, `HoleCounts` = шапка `renderExport` на всей матрице состояний); детерминизм (нет `time.Now`/`rand`/`os.Stat`/map-итерации в байты; 2 процесса под `TZ=Asia/Tokyo LANG=C` — идентично); заголовки (срез только известного префикса; pending-первый юнит с заголовком получает заголовок; пустой заголовок недостижим — `parseHeading`); nav вне spine; круг на трёх стендовых книгах воспроизведён независимо + epubcheck чист на adversarial-книгах; RTL — валиден, без `dir`, честно неподдержан; 2000 глав — 47 мс/707 КБ, ingest 38 мс; ids/hrefs по индексу; словесных литералов в Go нет; пин `Version()` подлинный; exit 16 в полосе, таблица тотальна; JSON аддитивен; `ApplyHeading` и три точки не тронуты; ни один тест не удалён; линтер 0.
**Obstacle reporting — что НЕ удалось и что осталось непроверенным.**
- **Не проверено — открывается ли в читалке.** Читалки на машине нет; «валиден» ≠ «открылся». Проверено ТОЛЬКО валидатором W3C (epubcheck 5.3.0, `0/0/0` ×3) и кругом через собственный ингест движка.
- **Не проверено — чистый бездрейфовый файл на реальной книге.** Недостижим на этом HEAD без платного перепрогона (все три копии под `config_drift: true`); чистый путь доказан синтетикой (`TestBuildBookRoundTripsThroughIngestAndPublishesItsPaths`: `complete: true`, `stale_unknown: false`, без пометок). Следствие: **stale-проверка на реальных книгах стенда не сделана** (`stale_unknown: true` у всех трёх — под дрейфом факт неразличим), доказана только харнесом.
- **Не проверено — сбой между двумя `rename`** (находка 3): ветка `write_incomplete` написана, не исполнена.
- **Не проверено — посадка на incomplete в `pipeline`-харнесе:** реальный member-drop только через бинарь (gap-харнесс `cmd/tmctl`) и acceptance (10/0, `length`); в `internal/pipeline` — синтетический `BookExport`.
- **Не проверено — направление письма.** `dir`/`page-progression-direction` не выставляются; данных о направлении в `internal/lang` нет; RTL-файл валиден, но выровнен слева — честно неподдержано (вопрос 4).
- **Не объяснено — read-only открытие ОРИГИНАЛЬНОЙ acceptance-БД.** `git -C books status --short -- gu-zhenren` до: 6 строк `D …-wal/-shm`; после: 4 — у `acceptance/guzhenren-acc-a.db` `-wal` (0 байт, 13:47:36 +03) и `-shm` (32 КБ, 13:47:49 +03) появились, идентичны трекнутым (`git diff` пуст), сама БД (mtime 24.08) не изменена. Мои команды все шли по копиям (`project_db`/`source_file` копии — в песочнице, проверено грепом; `.lock` копии — от миграции 12:56); тесты дерева acceptance-БД не открывают (греп); субагент-ревьюер завершился раньше этого времени. В `ListAgents` живы ещё три сессии (оркестратор и две, вероятно, полигонные) — кто-то из них открыл оригинал read-only. Файлы НЕ трогал (чужой репозиторий).
- **Опровергнуто (промтом):** «глава 1 minirun остаётся `""` даже с правилом» — на HEAD у minirun заголовок у всех 10 глав (`headings 10`, nav `Глава 1…Глава 10`); `""` у всех 25 глав acceptance — подтверждено.
- **Подтверждено:** числа промта §4 (minirun 14/14 ok · drift · оракул июля 14/14 по `final_text/snapshot_id/disposition`; acceptance 37 юнитов · ghost 20 · withheld 1/0 · incomplete 10/0 · drift; coldrun-a 14/14 pending · drift); миграции v10/v7/v14 → v16; epubcheck приносится и работает.
- **Ограничения формы (по промту, не исправлялись):** `dc:title` = `蛊真人` (перевод не выдумывается); автора нет; `text_modified` — «последняя ЗАПИСЬ» финальной строки ($0-резюм, переписавший строку без изменений, двигает штамп; между сборками детерминизм сохраняется); побайтовый детерминизм гарантирован для ОДНОГО бинаря (другой toolchain Go — иной deflate-поток при тех же документах); ghost-координата — старой нарезки (находка 9); `stale` под дрейфом — «неизвестно».
- **Самопойманная ошибка:** ложный прогон «батареи №2» (см. таблицу).
- **Эхо §7:** правок задания релеем владельца не поступало; единственное сообщение оркестратора (подтверждение адресата + напоминания) заказом не является; приор «артефакт рядом с ProjectDB + `StatusArtifacts`» принят с доводом.
**Вопросы владельцу (гейт между этим паком и дверью платформы, D25.1):**
1. **Лестница `run_complete / structurally_complete / quality_reviewed / publishable`:** писатель различает ровно «без дыр» (`complete: true`) и «с дырами» (отказ / `--partial` с пометкой). Что из лестницы печатать на титуле и в имени файла и какая ступень даёт копию БЕЗ пометки — решение владельца; в код не внесено намеренно.
2. **Лейбл главы без заголовка (К-3):** сейчас — номер цифрами. Словесный шаблон — одна строка в `reader.txt`, если ратифицируется; не сделано, чтобы не решать К-3 данными.
3. **Ghost и stale как дыры по умолчанию:** довод — оба дают «молча полную» книгу (acceptance: 20 строк не в файле при `pending 0`; правка исходника на месте: старый перевод под `complete: true`). Если владелец хочет «предупреждение, не отказ», — по строке в `BuildBook`.
4. **Направление письма для RTL-цели:** поле данных цели (в `reader.txt` или рядом), не Go — когда появится RTL-пара.
**Пинги оркестратору (чужие зоны, не правил):** (а) exit-код **16 `book_incomplete`** в таблицы `docs/glossary.md:64` и `docs/architecture/14-api-contract/README.md:731,758`; (б) `docs/architecture/09-target-architecture.md`: слой «писатель книги» построен; следующий шаг — дверь платформы `createExport`/`getExport` + `export_formats` из `bookfile.Formats`; дверь обязана СТРОИТЬ (`tmctl build --format <f> [--partial]`) и отдавать то, что построила, — не читать файл, лежащий рядом с БД (копия прежней сборки); (в) `projectRebill`/`status` слепы к правке исходника на месте при том же снапшоте (перекупка без предупреждения о деньгах) — строка бэклога; (г) `Status.checkWave` не видит исчезнувшую стадию (то же, что находка 10 у `Export`) — строка бэклога; (д) строки 160/201 не тронуты, `Heading` в `tm-export-v1` — тот же временный рендер (D39.122 п.2д), помечено в комментарии поля.
### 📝 ЗАПИСКА-ПЛАН — пак «деньги» (промт `docs/archive/prompts/BACKEND_MONEY_PACK_SESSION_PROMPT_2026-08-29.md`, D39.165 §1б + строка 231), сессия `textmachine-e4`, 28.08
**Скоуп:** `backend/` — §3.1 потолок ОБЪЁМА рядом с денежным · §3.2 смета пере-прохода без покупки. `platform/` не трогаю (проводка `CeilingChapters` — не мой заказ, честная граница §0 промта принята).
**§3.1 — В ЧЁМ МЕРИТЬ: выходной юнит (`ManifestUnit`), кандидат (а). Обоснование.**
Ровно эта гранулярность и есть то, что движок отгружает и что публикует манифест: `ManifestUnit` строится итерацией по `r.outputUnits(chunks)` (`backend/internal/pipeline/manifest.go:330-357`), то есть по тем же `editUnit`, которыми идёт волна редактуры; `units_total` манифеста — то самое поле, из которого интейк платформы пишет `chapters.units_total` (`platform/internal/pgstore/readmodel.go:113,166-175`, читал не правил). Значит перевод «N глав → M юнитов» у платформы ТОЧНЫЙ, а не оценочный: манифест снимается `tmctl manifest` за $0 и без ключей (`runner.go:239-244``models.CheckKeys` только `if forWrite`) ДО первой оплаты.
**Кандидат (в), `chunk×stage`, отвергнут именно по ловушке, названной в промте.** Это биллинговая единица (`rebill.go:322`), и в ней «10 единиц» — разный объём книги при разной форме конвейера: на юнит приходится `len(Members)*nDraftStages + nEditStages` позиций (`rebill.go:207`). Продавая (а) и каппя (в), мы воспроизвели бы дефект §0 слоем ниже. Кандидат (б), чанк, — внутренний артефакт нарезки, покупателю не виден и манифестом не считается.
**Честная оговорка, которую обязан назвать:** число юнитов книги зависит от ФОРМЫ конвейера — редакторский конвейер даёт крупные `editUnit`, черновой-только даёт по юниту на чанк (`waverun.go:67-76`). Это свойство конфигурации, а не ветвление по паре; но манифест и потолок обязаны сниматься с ОДНОГО деплоя, иначе «10 юнитов» значат разное.
**§3.1 — КАК СЧИТАЮТСЯ РЕПИНЫ И РЕТРАИ (прямой ответ на вопрос промта).**
- **Ретрай и эскалационный хоп — НЕ считаются.** Они внутри юнита: ретрай — итерация цикла попыток (`stagerun.go:158-194`), эскалация — отдельный `runAttempt` (`escalation.go`), но `chunk_status` на `chunk×stage` пишется ОДНА (`stagerun.go:297-308`). Довод по существу: объёмный потолок ограничивает ДОСТАВКУ, денежный — ТРАТУ. Хвост качества (1.5%→23% по D39.165 §1) — факт денежный, и у него уже есть свой потолок; если бы ретраи жгли объём, купивший 10 глав получал бы 8, потому что две главы заикнулись, — то есть платил бы КНИГОЙ за качественный хвост движка. Это ровно та подмена, которую пак и лечит.
- **Репины и $0-резюмы — НЕ считаются.** Иначе пере-проход, пере-привязывающий 500 юнитов за $0 (`rebill.go:315-320`), сжёг бы потолок, не доставив ничего.
- ⇒ **Правило одной строкой: потолок считает выходные юниты, по которым ЭТОТ прогон сделает хотя бы один провайдерский вызов.**
**§3.1 — МЕХАНИЗМ: пре-флайт скоуп, а не счётчик в воркере.**
Допуск считается ОДИН раз ДО волн, по состоянию стора, и даёт множество юнитов-в-работе; волны просто пропускают то, что вне скоупа. Предикат «$0-обслуживаемый» берётся из УЖЕ существующего — того же, которым считает `projectRebill`/`repin`, — чтобы объёмный стоп и денежная смета не могли разойтись двумя определениями.
**Почему не счётчик со слотами в воркере (рассмотрено и отвергнуто):** волна параллельна (`runWave`, `waverun.go:236-309`), и при отказе в слоте конкурентно розданные элементы пропадали бы НАВСЕГДА — при `workers=8` и потолке 1 семь юнитов оказались бы пропущены, а освободившийся после $0-репина слот уже некому было бы отдать. Пре-флайт эту гонку растворяет.
**Грабля LiteLLM исполнена конструктивно:** ни один элемент вне скоупа не НАЧИНАЕТСЯ, поэтому пере-бой на один вызов невозможен в принципе, а не по проверке.
**Грабля OpenHands исполнена:** остановка называет свой потолок — в `BookResult`, в строке лога и в выводе CLI.
**§3.1 — ФОРМА ОТВЕТА ЧЕРЕЗ ШОВ: беру решение оркестратора и ПОДТВЕРЖДАЮ его исполнением.**
Код выхода 0, замороженный словарь (`main.go:115-140`) не трогается. **Проверил то, о чём он просил пингнуть, — не ломает:** exit 0 у платформы = статус ПРОГОНА `ready` (`platform/internal/runs/reconcile.go:703-706`, читал не правил), а не «книга дочитана»; полоса книги едет событиями юнитов, поэтому дочитанной книга не объявляется и следующую покупку не блокирует.
**Словарь потока НЕ трогаю в этом паке.** Новое значение `Finished.Outcome` было бы минором по правилу `runevents.go:29-40`, но у платформы неизвестный ВЫХОДНОЙ код читается как отказ (`ingest/exit.go:168-169`), и множить развилки в чужом пакете без контрактного минора — не мой заказ (§3.3). Различение живёт в отчёте и логе, как и решил оркестратор.
**Ортогональность цела:** `--ceiling-usd` остаётся кумулятивным книжным капом и судит в `Reserve` (`ledger.go:56-72`); объёмный потолок леджера не касается вовсе и живёт до волн. Денег не считает, холдов платформы не двигает.
**§3.2 — ФОРМА: чинится СВЁРТКА, а не заводится глагол. Это дешевле всех трёх кандидатов промта.**
Слепое окно — не в том, что `status` пишущий, а в том, что он сворачивает НЕ ТО: `projectStoredMemory` (`status.go:815-826`) материализует СОХРАНЁННЫЙ глоссарий, то есть фолд ПРОШЛОГО прогона, тогда как ближайший `translate` пере-сеет банк из ФАЙЛОВ (`seedGlossary`) и сделает это ДО гейта согласия (`bookrun.go`: `seedGlossary` → … → `checkRebillConsent`). **Собственное оправдание комментария — «exactly as it does for `translate` itself» — неверно: `translate` правку файла как раз ВИДИТ.** Значит сегодня `status` НЕДО-считывает то, что `translate` реально выставит, и это денежный дефект, а не отсутствующая функция.
**Лечение:** фолд тех же входов, что берёт `seedGlossary` (сид + рубя + мined-дельта + авто-банк), — **В ПАМЯТИ, без единой записи**. `status` при этом остаётся ЧИСТО ЧИТАЮЩИМ: он читает файлы вместо стора, а фолд не пишет никуда. Возражение оркестратора против «флага у `status`» снимается тем, что оно стояло на посылке «свёртка идёт через стор» — она не обязана.
**Записи не будет и не может быть:** `status` открывает стор через `NewReadOnlyRunner``OpenReadOnly` с `PRAGMA query_only=1` (`store/store.go:117-166`), и на состоянии §4.3 (прогон СОСТОЯЛСЯ ⇒ база есть) фолбэк на `Open` не срабатывает вовсе — записей НОЛЬ, даже first-touch.
**Родня 124(в) закрывается тем же:** `status` уже считает ре-билл при ЛЮБОМ дрейфе, не только банковом, — то есть после фикса он и есть «оценить пере-проход, ничего не покупая», ради которого поименован `resnapshot --dry-run`.
**Третий кандидат (`bank-apply --dry-run`) рассмотрен ПЕРВЫМ, как заказано, и отклонён с доводом:** он требует `--decisions`, а в продуктовом потоке D39.165 §3 решения к моменту продажи УЖЕ на диске — документа больше нет. Плюс он Runner-free и не держит ни статусов, ни манифеста, ни конфига (`bankdecisions.go:294-314`), то есть под смету пришлось бы втащить в него весь Runner. Ту же проекцию он сможет позвать позже — она будет одной функцией.
**§3.2 — ТОЖДЕСТВО БЕСПЛАТНОЙ СМЕТЫ И ПЛАТНОГО ПРОГОНА: доказываю, а не предполагаю.**
Канонический фолд идёт через стор и там ПЕРЕ-СОРТИРУЕТСЯ: `ORDER BY src, sense, since_ch, until_ch, status, dst` (`store/glossary.go:223`). Порядок несущий дважды — `MaterializeBank` порядок входа НЕ трогает (`membank/memory.go:283-346`), а дальше он определяет и хеш версии (`ComputeVersionScopedIn` стримит SHA-256 по строкам в порядке итерации), и ЛИНИИ отрендеренного блока через стабильную сортировку бюджета (`memory.go:608-619`), которую режет токенный бюджет.
**Почему тождество ДОКАЗУЕМО, а не правдоподобно:** таблица несёт `UNIQUE (book_id, src, sense, since_ch, until_ch)` (`store/migrate.go:203`), поэтому префикс из четырёх колонок НЕ МОЖЕТ дать ничью — порядок ТОТАЛЬНЫЙ и полностью определён значениями, а не rowid и не порядком вставки. `COLLATE` в схеме нет нигде ⇒ сравнение текста побайтное, как и `<` в Go. Алиасы читаются `ORDER BY term_id, alias`, внутри записи это сортировка по `alias` (`glossary.go:247-249`). `glossary.id` в хеш не входит намеренно (`memory.go:360-363`).
⇒ Фолд в памяти обязан: собрать тот же набор значений + отсортировать по (Src, Sense, SinceCh, UntilCh, Status, Dst) + отсортировать алиасы + то же для voices/pairs по их собственным ключам.
**Проверка — ДИФФЕРЕНЦИАЛЬНАЯ, исполнением:** тест гоняет оба пути на одном состоянии и требует совпадения построчно (кроме `ID`), совпадения `Version()`/`BaseVersion()`, совпадения отрендеренных байтов и совпадения самого числа `projectRebill`. Ассерт равенства, а не «похоже».
**Пишущий путь НЕ трогаю:** порядок ВСТАВКИ в `seedGlossary` остаётся как есть, иначе поехали бы `glossary.id` и телеметрия `injected_ids`. Добавляется чистая функция упорядочения для читающей стороны.
**Находки по дороге (в блате пака, называю сразу):**
1. ⚠ **`projectRebill` в `status` рендерит по НУЛЕВОМУ базовому банку.** `projectStoredMemory` ставит только `r.memory`, `r.baseMemory` не ставит НИКОГДА (единственные присваивания — `seeding.go:157,159`), а ветка репина берёт именно его (`rebill.go:167`) и молча пропускает позиции при `nil` (`repin.go:130`). Следствие: при движении БАЗОВЫХ строк банка `status` считает всю черновую волну пере-оплатой, то есть расходится с `translate` — вопреки собственному обещанию `status.go:206` «status can never quote a different number».
2. ⚠ **При `snapshot_drift` у `status` память вообще не материализуется:** `projectStoredMemory` зовётся под `if !rep.SnapshotDrift` (`status.go:635`), а `projectRebill` — безусловно при наличии строк (`status.go:673`), и `memoryVersion()` при `nil` отдаёт хеш ПУСТОГО банка (`snapshot.go:190-195`) ⇒ книга объявляется пере-оплаченной целиком.
3. ⚠ **Мина `omitempty` подтверждена исполнением чтения:** три РАЗНЫХ состояния — «не запускалось», «посчитано и ноль», «расчёт упал» (`status.go:668-681` логирует и оставляет нули) — на проводе неотличимы. Это ровно цена D39.166 п.2.
Первые две мой фикс §3.2 снимает по построению (фолд считается безусловно и ставит ОБА банка); третью закрываю формой поля.
**НЕ делаю:** калибровку $0.03 (строка 202) · цену от объёма исходника · строки 228/230/141/131 · словарь кодов выхода · словарь потока событий · `platform/` · коммиты. Незакоммиченную работу полигона не касаюсь.
### ✅ ИТОГ — пак «деньги» (промт `docs/archive/prompts/BACKEND_MONEY_PACK_SESSION_PROMPT_2026-08-29.md`, D39.165 §1б + строка 231), сессия `textmachine-e4`, 29.08
**Батарея зоны:** `make battery`**21 пакет из 21, 0 FAIL**, линтер **0 issues**, `test -race` зелёный целиком.
**Проверяю ПОЛНОТОЙ СПИСКА, а не отсутствием `FAIL`, и это правило стоило мне двух ложных выводов.** Первый «зелёный» прогон был на 20 пакетов из 21 с `FAIL` в хвосте, который обёртка подала как `exit code 0`. Сверка: `comm -23 <(go list ./...) <(grep -E "^(ok|\?)" <лог>)` — пусто.
**И про `internal/store`.** Он дважды ронял батарею (`TestTheSeamIsNotChargedToTheStoreOperationBudget`, `apply migration 14: context deadline exceeded`), и я сначала списала это на память — **неверно.** Проверила как надо: развернула ЧИСТЫЙ HEAD через `git archive` и погоняла тот же тест в обоих деревьях вперемежку — **моё 4/5 падений, HEAD 4/5**, то есть от моих правок не зависит. Тест ужимает `opTimeout` до 150 мс (`migrate_test.go:408-429`) и под параллельной нагрузкой не укладывается; на разгруженной машине проходит 3/3, и итоговая батарея выше — с ним. Каталог `internal/store` я не трогала ни строкой. Оркестратору отправлено строкой.
Пропущенные тесты, как требует Makefile, называю: `TestMinerFullBookParity` (нет стендовых данных), `TestHelperEventsRun`, `TestHelperKillLoop` (точки входа подпроцессов, вне обычного прогона).
Дерево ПОСЛЕ трёх кругов: **20 отслеживаемых файлов правлено (+761/204) и 5 заведено (3106 строк)**; новых тестов **36** (30 в `internal/pipeline`, 6 в `cmd/tmctl`). Числа взяты командами: `git diff --stat -- backend/` для правленых (новые в него не входят — они untracked), `wc -l` для заведённых, `grep -c '^func Test'` для тестов. **Не коммичу — на лендинг.** ⚠ Числа выше по тексту, где они расходятся с этими, относятся к ПЕРВОМУ кругу и оставлены как след, а не как состояние.
**Мутационных посадок 26** (11 на первом круге, 15 на втором и третьем), каждая — на свой пункт, вердикт по ТОПИЧНОСТИ упавшего теста, а не по цвету батареи: снятие сортировки фолда · потолок на единицу больше · снятие допуска в черновой волне · снятие допуска в редакторской · «свободные юниты тратят грант» · возврат к прежней терминальности (воспроизводит перерасход) · `!ok``ok &&` (воспроизводит перерасход) · допуск только лидера (воспроизводит усечённую главу) · фильтр согласия в тождество (воспроизводит отказ законной работе) · перестановка лога замечаний (воспроизводит потерю диагностики) · снятие отказа несогласованной форме конвейера. Все одиннадцать падают, и падает ИМЕННО адресный тест.
#### Таблица комплектности против §3
| Пункт промта | Что сделано | Каким ИСПОЛНЕНИЕМ подтверждено |
|---|---|---|
| **§3.1** потолок ОБЪЁМА рядом с денежным | Флаг `--max-units N` у `translate`; единица — **выходной юнит** (`ManifestUnit`=`editUnit`, `units_total` манифеста); допуск считается ДО волн (`planVolume`, `backend/internal/pipeline/volume.go`), волны не начинают элемент вне скоупа | `TestTheVolumeCeilingStopsAtWhatWasBought` — 5 глав, потолок 2: отгружено ровно 2, `res.Volume` заполнен, у глав 35 **нет ни одной строки `chunk_status`**, вызовов провайдера ровно 4 (2 юнита × 2 стадии) |
| §3.1 — «проверять ПЕРЕД началом единицы» (грабля LiteLLM) | Свойство конструктивное: элемент вне скоупа не начинается вовсе | Тот же тест, клауза (3): отсутствие строк у N+1 и точный счёт вызовов; мутация «потолок на единицу больше» (`Granted <= MaxUnits`) — падают 3 теста |
| §3.1 — «остановка НАЗЫВАЕТ свой потолок» (грабля OpenHands) | `BookResult.Volume` (`VolumeStop{MaxUnits, Delivered, Reworked, Free, LeftFresh, LeftRework}` — ⚠ поля ПЕРЕСТРОЕНЫ на втором круге, см. находку 2), строка лога, строка вывода CLI | `TestAVolumeStopIsNotAMoneyStop` — денежный стоп остаётся типизированным `*CeilingHalt` (exit 4), объёмный не ошибка вовсе и несёт `res.Volume` |
| §3.1 — счёт РЕПИНОВ | Не считаются: считаются только юниты, по которым прогон сделает ≥1 вызов | `TestAResumeDoesNotSpendItsVolumeOnFreeUnits` — вторая покупка 2 юнитов на книге, где 2 уже сделаны: `Free=2`, `Delivered=2`, `LeftFresh=1`, свежих вызовов ровно 4. Мутация «свободные тратят грант» — тест падает |
| §3.1 — счёт РЕТРАЕВ | Не считаются: ретрай/эскалация внутри юнита | `TestRetriesDoNotBurnTheVolumeCeiling` — провайдер отдаёт `finish_reason: length` на первом черновике: юнитов всё равно 2, вызовов >4 |
| §3.1 — форма ответа через шов | Решение оркестратора исполнено: код выхода **0**, словарь `main.go:115-140` не тронут, словарь `runevents` не тронут | `res.ExitCode()==0` в тесте; сверено чтением `platform/internal/runs/reconcile.go:703-706` — exit 0 = статус ПРОГОНА `ready`, книга дочитанной не объявляется |
| §3.1 — ортогональность | `--max-units` не касается леджера, резерваций и кумулятивной семантики `--ceiling-usd` | `TestAVolumeStopIsNotAMoneyStop`; `MaxUnits` не входит ни в `BriefHash`, ни в снапшот-пейлоад |
| **§3.2** смета БЕЗ покупки | Читающие поверхности сворачивают банк **в памяти** из тех же входов, что `seedGlossary` (`projectFoldedMemory`), вместо СОХРАНЁННОГО глоссария | `TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought` — прогон состоялся → `bank-apply`**старая проекция 0, новая ≠0** на ОДНОМ состоянии |
| §3.2 — $0 абсолютный | Ни одного провайдерского вызова | Тот же тест: `rec.count()` не изменился; леджер (`committed`/`reserved`) не двинулся |
| §3.2 — read-only сверх first-touch | Ни одной записи | Тот же тест: **sha256 файла `.db` до и после совпадает**; `status` идёт через `NewReadOnlyRunner``OpenReadOnly` с `PRAGMA query_only=1` |
| §3.2 — ключей не требует | Путь не строит ни одного LLM-клиента (`models.CheckKeys` только `if forWrite`) | Пин `cmd/tmctl/keysfile_test.go` не тронут и зелёный |
| §3.2 — **тождество** сметы и прогона | `storeOrder` воспроизводит `ORDER BY` стора; фолд один на оба пути | `TestTheInMemoryFoldIsIdenticalToTheStoreRoundTrip`оба пути на одном состоянии: строки, voices, pairs, `Version()`, `BaseVersion()` и **карта отрендеренных content-hash** совпадают. Мутация «убрать сортировку» — падает с топичным «row 0 differs» |
| §3.2 — мина `omitempty` | `rebill_units`/`rebill_usd` больше не `omitempty`; добавлено `rebill_basis` (`pending`/`stored`/`none`/`failed`) | `TestTheWireTellsFreeFromUnknown` — «нечего пере-оплачивать» и «посчитано, и это ноль» на проводе различимы |
| §3.2 — книга остаётся инспектируемой | Отказ фолда не валит чтение: фолбэк на сохранённый глоссарий + честный `basis` + WARN | `TestTheEstimateSurvivesABookWhoseFoldRefuses` — сид-коллизия: `status` отвечает, `basis=stored` |
| §3.1 — потолок не отказывает законной работе И не даёт обойти порог дроблением | ⚠ **Строка ПЕРЕПИСАНА после отзыва первой редакции — см. второй круг, находка 1.** Порог судится по ВСЕЙ книге (дробить бесполезно); именованный кап `--accept-rebill=X` судится по тому, что заплатит ЭТОТ прогон (законная работа не отклоняется); прогон, который не пере-оплачивает ничего, гейта не встречает вовсе | `TestABoundedRunCannotOutrunTheConsentThreshold` (обе стороны на одном состоянии) + `TestARunThatRePaysNothingIsNotAskedForConsent` (третья ветка). Мутации: порог по прогону · кап по книге · снятие ранней ветки — все три падают адресно |
| §3.1 — потолок не даёт перерасхода на неполном юните | Терминальность = ПОКРЫТИЕ всех ожидаемых позиций (`unitFullyRecorded`), а не вердикт `resolveChunkState` | `TestAFlaggedMemberWithNoEditRowIsNotFree` — флагнутый член + ноль edit-строк; без фикса юнит признаётся свободным и оплачивается сверх потолка |
| §3.1 — неопределённость всегда в сторону «платно» | `!ok \|\| h != ContentHash` ⇒ невоспроизводимый хеш = платно | `TestAnUnreproducibleHashIsNeverFree` (6 состояний, включая `skipped` и снятую стадию) |
| §3.1 — допускается ВЕСЬ юнит, а не его лидер | Допуск помечает каждого члена | `TestTheCeilingAdmitsEveryMemberOfAUnit` — мелкая нарезка ⇒ 3 чанка в главе; мутация «только лидер» даёт усечённую главу |
| §3.1 — форма конвейера, где «юнит» двусмыслен | `planVolume` ОТКАЗЫВАЕТ (переводчик последней стадией при живых редакторских) | `TestTheCeilingRefusesAPipelineWhoseUnitsAreTwoThings`; без флага — поведение не меняется |
| §3.1 — черновой-только конвейер | Та же единица, другая форма | `TestTheVolumeCeilingHoldsOnADraftOnlyPipeline` — 1 вызов на юнит, 2 гранта = 2 вызова |
| §3.1 — стоп назван в выводе CLI | Строка с тремя действенными числами + «это ЗАВЕРШЕНИЕ, не пауза» | `TestRenderTranslateNamesTheVolumeCeiling`; необрезанный прогон о потолке молчит |
| §3.2 — «неизвестно» никогда не несёт числа | Решение вынесено в чистую `rebillOutcome`/`projectable` | `TestAFailedBasisNeverCarriesAFigure` — таблица на 5 состояний |
| §3.2 — падающий фолд не уносит свою диагностику | Замечания в лог ДО обработки ошибки | `TestAFailedFoldStillReportsWhatItSkipped` |
| §3.1 — отчёт различает ДОСТАВКУ и ПЕРЕ-ДЕЛКУ (добавлено 2-м кругом) | `VolumeStop` раздвоен на `Delivered`/`Reworked` и `LeftFresh`/`LeftRework`; грант идёт сперва в новое; приглашение купить — только про никогда-не-доставленные | `TestAFullyDeliveredBookOffersNothingLeftToBuy` (0 доставлено / 1 переделан / 0 к покупке) · `TestAPurchaseBuysNewBookBeforeReMakingOldBook` (грант уходит в новое, а не в переделку) · `TestRenderTranslateNeverSellsWhatIsAlreadyOwned`. Мутация «схлопнуть два прохода в один» падает |
| §3.1 — движок говорит ЮНИТАМИ, не главами (добавлено 2-м кругом) | Все строки отчёта и CLI переведены на «units»; главы — слово платформы, конвертирует манифест | `TestRenderTranslateNamesTheVolumeCeiling` + `TestAFullyDeliveredBookOffersNothingLeftToBuy` пинят формулировку |
| §3.2 — смета дана и в той единице, которой сайзится покупка (3-й круг) | Аддитивное `rebill_output_units` рядом с `rebill_units` (chunk×stage); имя `rebill_units` не тронуто | `TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn`; фикстура специально недегенеративна; мутация «выдать Rows за юниты» падает |
| §3.2 — один документ = один банк (2-й круг) | `unsigned_bank_terms` считается по ТОМУ ЖЕ банку, что и денежные поля | Материализация помнит свои строки (`r.bankRows`); проверено батареей |
| §5 ось 3 — детерминизм | `MaxUnits` не входит ни в снапшот, ни в `BriefHash`; байты не двигаются | `TestTheVolumeCeilingMovesNoRequestByte` — снапшоты обеих волн не сдвинулись, каждое тело обрезанного прогона есть среди тел безпотолочного; мутация «подмешать MaxUnits в снапшот» падает |
| §3.3 — чего в паке нет | Константу $0.03, цену от объёма исходника, строки 228/230/141/131, `platform/` — не трогал | — |
#### Адверсариальный проход по СВОЕЙ готовой работе — что он нашёл
Веер: шесть линз (деньги · тождество фолда · шов · регрессии пака · параллелизм/детерминизм · качество моих же тестов), тяжёлая модель — РОВНО ОДИН агент (денежная линза), остальным попроще; затем по одному независимому опровергателю на каждую находку. Сырых находок 10, после опровержения осталось 7 настоящих. **Все семь внёс сам этот пак — приёмке они не видны, поэтому называю первыми.**
**1. ⛔ ПЕРЕРАСХОД: юнит с флагнутым членом и без edit-строки считался БЕСПЛАТНЫМ.** `unitsServedFree` спрашивал терминальность у `resolveChunkState`, а тот отдаёт `ChunkFlagged`, как только флагнута ЛЮБАЯ строка, — ДО проверки, что все ожидаемые позиции на месте. Юнит, у которого черновик одного члена флагнут, а edit-строки ещё нет (состояние после подписного стопа банка, Ctrl-C или любого обрыва в редакторской волне), признавался терминальным и свободным, проходил БЕЗ списания гранта и потом оплачивался редактором. Прогон платил за юнитов больше, чем куплено, и, поскольку остаток оставался нулём, **даже не сообщал об объёмном стопе.**
**Лечение:** предикат заменён на `unitFullyRecorded` — покрытие ВСЕХ ожидаемых позиций строками; полнота и есть терминальность, и именно она предсказывает «вызова не будет». **Доказано:** `TestAFlaggedMemberWithNoEditRowIsNotFree` (обрыв редактора на прогоне 1 → флагнутый член + ноль edit-строк; прогон 2 с потолком 1). Возврат к прежнему предикату мутацией — тест падает с «the other unit was judged free and paid for anyway».
**2. ⛔ ОТКАЗ ЗАКОННОЙ РАБОТЕ: гейт согласия судил ВСЮ книгу при обрезанном прогоне.** Нашёл сам, до линз. `checkRebillConsent` стоял ПЕРЕД `planVolume` и проецировал пере-оплату по всем строкам, тогда как прогон с `--max-units 2` пере-оплатит максимум два юнита. Р6 требует согласия на КОНКРЕТНУЮ трату — а тут отказ (или запрос согласия) шёл на деньги, которые никто не потратит. Это ровно то, о чём предупреждает сам `rebill.go`: «число, которое одобрили и не списали, — это то, из-за чего такие числа перестают читать».
**Лечение:** скоуп считается ДО гейта; СУММА сужена до допущенных юнитов, ПОРОГ оставлен книжным (5% книги — свойство книги, не покупки). **Инертно без флага** (`scope==nil` ⇒ фильтр тождественный ⇒ число байт-в-байт прежнее) — это и делает правку ратифицированного гейта безопасной. **Доказано на первом круге** мутацией «фильтр в тождество». ⚠ **Это решение ОТОЗВАНО на втором круге** (находка 1 ниже) — читать вместе с ним, а не отдельно.
**3. Отказ `redrive` учил ЛОЖНОМУ факту об движке.** Текст говорил «`redrive` не гоняет книжную волну, которую это могло бы ограничить» — неправда: `redrive` зовёт `TranslateBook` (`status.go:984`). Мой же комментарий четырьмя строками выше говорил обратное и верное. **Лечение:** у `redrive` теперь свой текст, называющий НАСТОЯЩУЮ причину (деструктивный сброс ограничен своим селектором, и потолок в юнитах ограничил бы вторую половину команды, читаясь как ограничение целого). Тест дотянут: он проверяет не только имя флага, но и что причина не врёт про адресата.
**4. Рефактор `seedGlossary` терял диагностику падающего прогона.** Замечания (пропуск ruby-алиаса по омонимии, дроп строк авто-банка) собирались в `gatherBankInputs`, но логировались ПОСЛЕ проверки ошибки — то есть на прогоне, который умер на более поздней проверке, они пропадали. До рефактора они писались по месту и доезжали. **Лечение:** замечания уходят в лог ДО обработки ошибки; `gatherBankInputs` для этого возвращает частичные входы вместе с ошибкой. **Доказано:** `TestAFailedFoldStillReportsWhatItSkipped`; перестановка двух блоков мутацией — тест падает.
**5. `rebill_basis: failed` мог ехать рядом с большим ПОСЧИТАННЫМ числом.** Если банк не материализовался вовсе, `projectRebill` всё равно запускался — против ПУСТОГО банка, поэтому все строки выглядели протухшими и на провод уезжала пере-оплата всей книги, а CLI печатал рядом «цифры ниже нули, потому что их не удалось посчитать». Базис, противоречащий собственным числам, хуже отсутствия базиса. **Лечение:** решение вынесено в чистую `rebillOutcome` (+ `projectable`) — проекция не запускается без банка, «неизвестно» никогда не несёт числа. Табличный тест на все пять состояний; **инвариант недостижим из фикстуры** (нужен одновременный отказ и фолда, и стора), поэтому он и вынесен в функцию — иначе его нечем было бы держать.
**6. Потолок отказывается от формы конвейера, в которой «юнит» — два разных объекта.** Если ПОСЛЕДНЯЯ стадия — переводчик, а редакторские стадии есть (конфиг такой порядок не запрещает), `outputUnits` отдаёт синглтон на ЧАНК, а редакторская волна всё ещё группирует их в юниты: потолок, выданный в синглтонах, допустил бы чанк одного члена, и редактор пошёл бы по юниту с недоделанным черновиком за полную цену. **Лечение:** `planVolume` на такой форме ОТКАЗЫВАЕТ громко (без флага — ничего не меняется). Тест + мутация.
**7. Мои собственные тесты доказывали меньше, чем обещали — три дыры, все закрыты.**
(а) **Мульти-членный юнит не проверялся ни одной фикстурой** (все главы были однопредложенные ⇒ у каждого юнита ровно один член), поэтому цикл по `u.Members` в допуске был непокрыт: мутация «помечать только лидера» проходила ВСЮ батарею. Закрыто `TestTheCeilingAdmitsEveryMemberOfAUnit` (мелкая нарезка черновика под крупным редакторским потолком ⇒ 3 чанка в главе); мутация теперь падает с «buyer gets a truncated chapter they paid for in full».
(б) **Ветка «хеш не воспроизведён ВООБЩЕ» была непокрыта**а это направляющий инвариант всего потолка («любая неопределённость — в сторону „платно“»). Мутация `!ok || …``ok && …` проходила батарею. Закрыто `TestAnUnreproducibleHashIsNeverFree`.
(в) **Тест тождества фолда сравнивал ТОЛЬКО черновую волну.** Путь A не гонял `TranslateBook`, поэтому строк `chunk_status` не было, и `renderedContentHashes` не давал ни одной edit-позиции — а edit-волна ровно та, куда попадают `mined`-строки фикстуры (базовый банк их исключает). То есть половина, ради которой фикстура и заводилась, не проверялась. Закрыто: перед сравнением идёт настоящий прогон, и **покрытие обеих волн теперь АССЕРТИТСЯ**, а не предполагается (`len(hashes)!=0` от этого не защищал: две черновые карты тоже равны).
⚠ **ОТДЕЛЬНОЙ СТРОКОЙ, потому что это правка РАТИФИЦИРОВАННОГО контура и она обязана быть видна в акте, а не выводиться из диффа: я тронула гейт согласия D20.2-Q2.**
**ЧИТАТЬ ВМЕСТЕ С НАХОДКОЙ 1 ВТОРОГО КРУГА — здесь описана ПЕРВАЯ, ОТОЗВАННАЯ редакция.** Она сужала СУММУ до допущенных юнитов при книжном ПОРОГЕ и защищалась доводом «инертно без флага». Довод верный и неважный: с флагом размер прогона выбирает каллер, и дробление обходило порог. **Действующая редакция:** порог — по всей книге (дробить бесполезно), именованный кап — по тому, что заплатит этот прогон (законная работа не отклоняется), прогон без пере-оплаты гейта не встречает. Инертность без флага сохранена и в ней: при `scope == nil` обе проекции — один объект, и число байт-в-байт прежнее.
**Опровергнуто и НЕ чинилось (3 из 10):** три находки опровергатель снял как уже вылеченные тем же проходом (я чинил по мере поступления, и опровергатели читали уже исправленный код) — они и есть пункты 3, 4 и 7(а/б) выше.
#### Второй круг приёмки — что нашли и что я с этим сделала
Оркестратор прогнал по паку ещё два аппарата: слепой верификатор (читал ТОЛЬКО заказ и дифф, отчёта не видел) и искателя вне карты отчёта. Плюс я прогнала свой второй код-ревью — шесть линз, тяжёлая модель один агент, по опровергателю на находку. Итог круга: **двенадцать настоящих находок, все внесены паком, все вылечены** — плюс одна чужая, проверенная и переданная строкой.
**1. ⛔ ОТЗЫВ МОЕГО ЖЕ РЕШЕНИЯ: сужение суммы гейта D20.2-Q2 открывало обход порога дроблением.** Первый круг я закрыла тем, что сумму пере-оплаты считаю по допущенным юнитам, а порог оставляю книжным, и защитила это доводом «правка инертна без флага». Довод верный и НЕважный: с флагом каллер САМ выбирает, насколько мал каждый прогон, поэтому `--resnapshot --max-units N` в цикле пере-оплачивает книгу целиком, ни разу не перейдя порог и ни разу не спросив согласия. Измерено искателем на реальных числах: 30 глав за $0.109200, порог $0.005460, безпотолочный прогон ОТКАЗЫВАЕТ, а `--max-units 1` проходит четыре раза подряд по $0.003640. Порог обхода арифметический: покупка ≤5% книги — гейт молчит; на 500-главной вебновелле «10 глав» это 2%.
**Лечение — разделены два РАЗНЫХ вопроса, а не один компромисс.** «Надо ли вообще спрашивать человека» — политика КНИГИ, судится по всей её пере-оплате, которая от размера покупки не сжимается. «`--accept-rebill=X`» — инструкция каллера о ТРАТЕ, судится по тому, что этот прогон реально заплатит (платформа фондирует этот кап из холда прогона, то есть он размерен покупкой, а не книгой). Отказ печатает ОБА числа: собрать их в одно — и есть способ соврать в любую сторону.
**Доказано:** `TestABoundedRunCannotOutrunTheConsentThreshold`обе стороны на одном состоянии: потолок 1 без согласия ОТКАЗЫВАЕТ (обход закрыт), а именованный кап $0.002, покрывающий собственный счёт прогона ~$0.00182, ЧЕСТЁН (законная работа не отклонена).
**2. ⛔ `VolumeStop` не различал ДОСТАВКУ и ПЕРЕ-ДЕЛКУ — и это была нехватка модели, а не сбой счётчика.** Один счётчик на оба вида оплаченного юнита давал ложь, на которую оператор действует: книга переведена на 100%, банк подвинут, покупка №1 печатает «29 остались», №2 — «28», ни одной новой главы не создано, exit 0 и `ready` подтверждают успех. А CLI подавал остаток как «что осталось КУПИТЬ».
**Лечение — переделан тип, а не подлатан счёт.** Юнит классифицируется тремя состояниями (`unitFresh` / `unitFree` / `unitRework`), и обе половины отчёта раздвоены: `Delivered`/`Reworked` и `LeftFresh`/`LeftRework`. Приглашение купить произносится ТОЛЬКО про никогда-не-доставленные юниты; покупка, целиком ушедшая в переделку, обязана это сказать. Раздвоение бесплатно: оно падает из того же теста полноты позиций, которым считается «бесплатно».
**Доказано:** `TestAFullyDeliveredBookOffersNothingLeftToBuy` (0 доставлено, 1 переделан, 0 никогда-не-доставленных, остаток назван пере-проходом) + `TestRenderTranslateNeverSellsWhatIsAlreadyOwned` (строка CLI не предлагает купить то, чем уже владеют).
**3. ⛔ `status`/`export` начали читать авто-банк, который писался НЕ атомарно.** Дыра, которую пак открыл в чужом файле: до него авто-банк читал только тот прогон, который его и писал, поэтому `os.WriteFile` с усечением был безопасен. Как только читающие поверхности стали сворачивать банк сами, файл попал в класс, ради которого существует `artifact.go`, — и окно усечения даёт либо ошибку разбора (покупателю устаревшие цифры), либо валидный ПРЕФИКС списка термов: фолд банка, которого не существовало, и `rebill_units`, который ни один прогон не выставит.
**Лечение:** `writeFileAtomic`, как у трёх соседей; авто-банк вписан в перечень сайдкаров в шапке `artifact.go`. **Доказано:** `TestTheAutoBankIsWrittenAtomically` — инструмент inode (переименование меняет его ВСЕГДА, запись на месте — никогда; сильнее сверки байтов).
**4. Пин согласия `status``translate` покрывал только беспотолочный случай.** Механическая правка сигнатуры протащила туда `nil`, и расхождение под потолком пин бы не поймал. Дописан второй ассерт: под объёмным потолком отказ гейта обязан по-прежнему цитировать КНИЖНОЕ число — то самое, которое печатает `status`, — потому что докстринг `status.go` обещает это дословно.
**5. Отказ денежного потолка съедал объёмный отчёт.** Когда прогон встаёт по деньгам, результата нет, и оператор не узнаёт, что ему было выдано и сколько успело доехать. Добавлена строка WARN на этом пути: стоп ниже — НЕ объёмный, вот что было выдано и что осталось недоставленным.
**6. Инструмент «ничего не записано» был слеп к WAL — и, дотянутый, сразу поймал факт.** Дайджест мерил только основной файл, а SQLite в WAL кладёт коммит в `-wal`. Дотянула — и увидела изменение. **Разобрала, а не подогнала:** данные не двигались (основной файл байт-в-байт), а появились ПУСТОЙ `-wal` и `-shm`: так устроен любой WAL-читатель. Поэтому ассерт теперь точный: основной файл неизменен И журнал не несёт ни одного кадра; `-shm` не проверяется намеренно, требовать его отсутствия значило бы требовать от SQLite невозможного. Гарантию держит `query_only`, дайджест — подтверждение.
**Косметика того же круга:** висячие ссылки на `bankfold.go`/`bankfold_identity_test.go` исправлены на реальные `bankmaterialize*` · `backend/README.md` (позиция №2 онбординга зоны) теперь знает про ВТОРОЙ потолок, его ортогональность денежному, «стоп = завершение» и предупреждение про майнящие книги · в `volume.go` явно записано, что нулевое значение `unitClass` обязано быть `unitFresh` (пустая карта = книга, которую не гоняли).
**7. ⛔ ПОТОЛОК ТРАТИЛ ПОКУПКУ НА ПЕРЕ-ДЕЛКУ ВПЕРЁД ДОСТАВКИ.** Допуск шёл одним проходом в порядке книги, а после правки банка уже доставленные юниты — это как раз РАННИЕ. Воспроизведено ревью: покупка 2 юнитов на пятиглавой книге с двумя доставленными пере-делала главы 12 и не начинала главу 3 — `Delivered=0, Reworked=2, LeftFresh=3`. Деньги списаны, книга не сдвинулась ни на главу, а три никогда не переводившиеся главы остались нетронутыми.
**Лечение:** грант идёт СНАЧАЛА в новое, и только потом в переделку. Безопасно потому, что байты юнита не зависят от того, какие ДРУГИЕ юниты шли: липкая выборка считается по ВСЕЙ книге до обеих волн (`precomputeSticky`), поэтому юнит 3 раньше юнита 1 рендерится ровно так же. **Размен назван, а не спрятан:** покупка, ЦЕЛЬЮ которой был пере-проход, теперь потратит грант на недоставленные главы, если они есть. Это правильное умолчание — непрочитанная глава ценнее пере-сделанной, а пере-оплату и так ограничивает гейт согласия, — но это УМОЛЧАНИЕ, и я выношу его оркестратору как выбор, а не как факт.
**8. ⛔ МОЯ ЖЕ СТРОКА СТОПА СООБЩАЛА ЮНИТЫ КАК ГЛАВЫ.** `«%d NEW chapter(s) delivered»` печаталось поверх счётчика, который инкрементится на `editUnit`, — а глава это больше одного юнита всякий раз, когда нарезка закрывает юнит по редакторскому потолку, и много юнитов в черновом-только конвейере. **То есть подмену «юнит↔глава», ради устранения которой пак и заведён, совершал отчёт самого пака** — и мой же тест пинил эту формулировку как инвариант. Воспроизведено: одна глава, три юнита, потолок 2 → «2 NEW chapter(s) delivered» на книге из ОДНОЙ незавершённой главы.
**Лечение:** движок говорит только «units», везде. Главы — слово ПЛАТФОРМЫ, и конвертирует их она, манифестом. Тесты пере-пинены на верную формулировку.
**9. Один документ `status` описывал ДВА банка.** Денежные поля (`rebill_*`) считались от свёртки файлов решений, а `unsigned_bank_terms` — по-прежнему от СОХРАНЁННОГО глоссария. Оператор, спрашивающий «есть ли что подписать, прежде чем платить дальше», получал ноль, тогда как ближайший `translate` уже собирался инжектить неподписанную строку из авто-банка и оплатить изменённый ею юнит. **Лечение:** материализация запоминает строки, из которых собрана, и счёт неподписанных идёт по ТОМУ ЖЕ банку. Один документ — один банк.
**10. Цифры в отказе гейта были СМЕШАННОГО происхождения.** В книжную фразу вклеивался скоупный `Repinned`, то есть в предложении, где все остальные числа книжные, стояло меньшее число из другой проекции. Две линзы поймали независимо — что смешанные провенансы в одном предложении и производят. **Лечение:** каждое число берётся из той же проекции, что и фраза; скоупная оговорка несёт скоупный ре-пин.
**11. `export` ОТКАЧЕН — и это отказ от собственной правки, а не находка чужого.** Я перевела его `ConfigDrift` на свёртку «за компанию» со `status`. Но это поле читает полигонная экстракция, и её вопрос ДРУГОЙ: «согласован ли ЭТОТ документ с прогоном, который его произвёл». Экспорт, байты которого — ровно то, что отгрузил прогон, измерим и при лежащем рядом неприменённом файле решений; свёртка помечала бы такой экспорт дрейфующим и тихо дисквалифицировала бы здоровые замеры в чужой зоне. **Пере-определять чужое потребляемое поле как побочный эффект своего заказа — не моё право.** Расхождение теперь ЯВНОЕ и запинено `TestExportAndStatusAnswerDifferentDriftQuestions`, чтобы следующий не «починил» его обратно.
**12. Дыры в моих же тестах, найденные второй линзой качества:**
(а) **ни один тест не звал CLI-`translate` с `--max-units`** — то есть единственная строка, которая проводит флаг в движок (`r.MaxUnits = maxUnits`), была не проверена вовсе, и её удаление оставило бы всю батарею зелёной, пока `tmctl translate --max-units 2` переводит книгу целиком. Закрыто `TestTranslateWiresMaxUnits` по образцу соседнего `TestTranslateWiresAcceptRebill`; мутация «убрать проводку» падает с точным сообщением.
(б) **ветка «прогон не пере-оплачивает ничего» не была покрыта** — её удаление проходило всю батарею, а она ровно то, что не даёт гейту отказать законной работе. Закрыто `TestARunThatRePaysNothingIsNotAskedForConsent` (состояние строится через `ResetChunkStages`, как делает redrive).
(в) **`export` после правки банка не был покрыт ничем** — теперь покрыт, вместе с решением о расхождении.
**13. И находка НЕ из моей зоны, проверенная как положено.** `internal/store` дважды уронил мою батарею (`TestTheSeamIsNotChargedToTheStoreOperationBudget`, `apply migration 14: context deadline exceeded`). Я этот каталог не трогала ни строкой. Развернула ЧИСТЫЙ HEAD (`git archive`) в отдельный каталог и погоняла тот же тест в обоих деревьях вперемежку по пять раз: **моё падает 4/5, HEAD падает 4/5** — от моих правок не зависит. Причина в самом тесте: он ужимает `opTimeout` до 150 мс (`migrate_test.go:408-429`), и под параллельной нагрузкой одиночный ALTER в него не укладывается. Оркестратору отправлено строкой; сама не чиню — чужая зона.
**И правило, которое из этого следует, шире случая: ЗЕЛЁНАЯ БАТАРЕЯ — ЭТО ПОЛНЫЙ СПИСОК ПАКЕТОВ ПЛЮС ОТСУТСТВИЕ `FAIL`, а не отсутствие `FAIL`.** Мой первый «зелёный» прогон был на 20 пакетов из 21, с `FAIL` в хвосте, который обёртка подала как `exit code 0`. Сверяю против `go list ./...` теперь всегда.
**И одна поправка к моему же первому отчёту.** В записке-плане я написала, что предикат «бесплатно» — «тот же, которым считает `projectRebill`, чтобы они не разошлись двумя определениями». Это НЕВЕРНО и я поправляю: `projectRebill` намеренно игнорирует ось СОДЕРЖИМОГО (его собственный докблок это и говорит — правка исходника двигает `content_hash`, но не снапшот, и там она невидима), а предикат потолка сверяет отрендеренный хеш на КАЖДОЙ строке. То есть они расходятся, и расходятся в безопасную сторону: потолок считает платным то, что смета считает бесплатным. Предикат написан против ИСПОЛНИТЕЛЯ (`runStage`), а не заимствован у проекции, и в `volume.go` это теперь записано явно.
#### Третий круг — ратификация ОПУСАМИ трёх развилок, где я сама не была уверена
Владелец снял потолок на веер и назвал главное: **ратификаторы — опусы, особенно на важном.** Поэтому три развилки, которые я не имела права закрыть своим словом, ушли трём опус-судьям с мандатом «настаивай на своём, а не соглашайся». Плюс второй код-ревью в шесть линз с опровергателями (15 сырых находок, 4 пережили). Модель каждому агенту задавалась ЯВНО.
**Развилка 1 — «капнутое согласие: дыра или согласие?» → ВЕРДИКТ: ЗДОРОВО, менять нечего.** Ревьюер показал цикл: `--resnapshot --max-units 1 --accept-rebill=0.002` четыре раза пере-оплачивает книгу целиком, ни разу не перейдя порог. Ратификатор воспроизвёл цикл СВОИМ прогоном и разобрал по существу: (а) каждый прогон явно называет сумму и списывает ровно её (замерено: заявлено $0.001820 — списано $0.001820); (б) каждый прогон ПИШЕТ рядом книжную цифру и порог, то есть каллеру прямо сказано, что книга выкупается по кусочку; (в) суммарно цикл не достигает ничего, чего одиночный голый `--accept-rebill` не достигает за один вызов; (г) кумулятивный денежный потолок дроблением не обходится. **Это Р6 в чистом виде — согласие на КОНКРЕТНУЮ трату, данное явно и многократно.**
**Его же рекомендация принята:** свойство, которое делает это согласием, а не дырой, — раскрытие — раньше не было запинено ничем. Теперь есть `TestAConsentedSliceIsAlwaysToldWhatTheBookCarries`; мутация «убрать книжные цифры из строки согласия» падает.
**Развилка 2 — «денominация двух половин пака» → ВЕРДИКТ: ДЕФЕКТ, ЧИНИТЬ.** И он измерил, а не вывел: на трёхглавой фикстуре один документ `status --json` нёс `rebill_units: 15` двумя строками ниже `total_units: 6`. Все остальные «units» в документе — ВЫХОДНЫЕ юниты (`total_units`, прогресс, `chapters[].units_total`), а `rebill_units` единственный считает `chunk×stage`, и коллизия сидит в самом слове. **Поделить нельзя:** отношение `len(Members)·nDraftStages + nEditStages` меняется от юнита к юниту ВНУТРИ книги, а его множители на провод не ходят вовсе. Траектория: платформа берёт смету, которую пак сам просит вернуть в аллоулист, и передаёт это число в `--max-units` — покупает втрое больше книги, чем собиралась. **Дефект пака, воспроизведённый слоем ниже, ровно тот, ради устранения которого пак заведён.**
**Лечение — его форма, аддитивная:** `rebill_units` НЕ переименован (устоявшееся имя, смысл не менялся), рядом заведено `rebill_output_units` — та же пере-оплата в той единице, которой сайзится покупка. Человеческий рендер печатает обе с подписями. `RebillProjection.OutputUnits` считается по лидерам затронутых юнитов там же, где инкрементится `Rows`. **Доказано:** `TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn` — фикстура специально сделана недегенеративной (правка КОНФИГА, а не банка: банковая правка двигает только редакторскую волну и обе денominации совпали бы, и тест ничего бы не доказал); мутация «выдать `Rows` за выходные юниты» падает.
**Развилка 3 — «безопасно ли ставить доставку впереди переделки» → ВЕРДИКТ: ЗДОРОВО, и обосновано замером, а не рассуждением.** Ратификатор проверил три вещи исполнением. (а) **Порядок безопасен:** `precomputeSticky` — чистая функция от (чанки, банк, бюджет), считается ДО допуска и потребляется позиционно, поэтому инъекция чанка i не зависит от того, шёл ли чанк i1; редакторская волна берёт только СВОИ члены и делает свежий Select с nil-sticky. Он прогнал книгу, где глава 4 переведена и отредактирована в прогоне, где глава 1 не запускалась вовсе, — **тела запросов дословно совпали с телами прямого сплошного прогона.** Проверил и остальные кандидаты на кросс-юнитную связь: стоп майнинга строит кандидатов по ВСЕМ чанкам (от скоупа не зависит), свёртка банкнот — множественная (порядок не важен), счётчики событий засеиваются книжно и инкрементятся под once-key. (б) **A/B на одной книге, четыре покупки по 2 юнита:** доставка-вперёд отдаёт 6/6, порядок книги — 4/6, причём при порядке книги две покупки подряд печатали «Still in the book: 4 unit(s) NEVER delivered» не сдвинувшись. (в) **Дверь пере-прохода не сломана:** на полностью доставленной книге доставка-вперёд ЕСТЬ порядок книги (свежих юнитов нет), то есть чистый пере-проход работает как прежде.
**Его требование выполнено — и оно было существенным:** это не живой дефект, а ТРОПА ГНИЕНИЯ. Схлопывание двух проходов обратно в один оставляло батарею ЗЕЛЁНОЙ, то есть следующая сессия, «прибирающая» цикл или добавляющая четвёртый класс юнита, тихо вернула бы дефект. Закрыто `TestAPurchaseBuysNewBookBeforeReMakingOldBook`; мутация «схлопнуть в один проход по порядку книги» падает — и падает ровно на вторичном следствии, которое ратификатор и предсказал: при порядке книги та же покупка не просто перераспределяется, а ПОЛУЧАЕТ ОТКАЗ гейта согласия.
**Дополнительно из второго ревью (пережили опровержение и вылечены):**
**тройное повторение самого дорогого расчёта.** Обрезанный прогон пере-рендеривал всю книгу до трёх раз: в `planVolume` и в каждой из двух проекций гейта, при том что между ними ничего не менялось. Заведён мемо, **срок жизни которого — жизнь БАНКА**: `materializeBanks` его сбрасывает, потому что переживший свой банк мемо отдал бы хеши банка, которого у прогона больше нет, — ровно тот класс тихой неверности, ради устранения которого пак и существует. Мемо — скорость, а его инвалидация — корректность, поэтому запинена именно она (`TestTheWireHashMemoDiesWithItsBank`).
**предупреждение про майнящие книги не было покрыто ничем**а незакрытое тестом предупреждение молча перестаёт эмититься. Закрыто, вместе с обратной стороной: на книге БЕЗ потолка оно не должно возникать.
#### Четвёртый круг — денежный охотник приёмки: 6 находок, 3 вылечены, 3 переданы
**1. ⛔ БЛОКИРУЮЩАЯ, и она пробивала саму цель пака — ВЫЛЕЧЕНА.** `planVolume` классифицирует ДО черновой волны, но между волнами стоит стоп майнинга, который **пере-сеивает банк прямо посреди прогона** (`mining.go`, ветка авто-продолжения), после чего `waverun` пере-считывает edit-снапшот. А free-юниты допускаются ВНЕ гранта — потому что бесплатная работа ничего не стоит. Значит каждый из них судился по снапшоту, который прогон сам же и заменил, и любой, чьи инжектируемые байты новый банк изменил, получал свежий ПЛАТНЫЙ вызов редактора, не разрешённый никаким грантом, — а строка отчёта называла его «rode along at $0». Охотник замерил: 4 вызова при гранте 1, $0.007280 объявлены бесплатными.
**Лечение — не гард, а ПЕРЕ-ПЛАН:** вопрос «бесплатен ли юнит» задаётся заново, против снапшота, который стал реальным, в последний момент перед редакторской волной — то есть «проверять перед единицей» по-прежнему держится. Ставший платным берёт слот, если он есть; если нет — его редактура НЕ идёт, и он честно отчитывается как доставленный-но-не-пере-сделанный.
**И сделано СТРУКТУРНО, а не вызовом:** волна ОТКАЗЫВАЕТСЯ работать со скоупом, спланированным против другого снапшота. Без этого удаление вызова пере-плана оставляло всю батарею зелёной, а потолок молча переставал держать — я это проверила мутацией. Теперь удаление вызова роняет прогон с адресным сообщением.
**Доказано:** `TestFreeUnitsAreReJudgedWhenTheBankMovesMidRun` (снапшот не двигался → ничего не меняется; двигался → ни одного «free», ровно один слот занят, остальные удержаны и отказаны `allowsUnit`) + `TestTheEditWaveRefusesAStalePlan`. Две мутации.
**Своей фикстурой конечное следствие я не воспроизвела — ПЯТЬ попыток, — и разбор этого провала оказался ценнее самих попыток.** Две причины, обе названы приёмкой и обе проверены мной на своём дереве.
**Причина 1: я искала не на той стороне дельты.** У mined-дельты две стороны. **WHICH** (список термов) майнится из исходника ВСЕЙ книги — он полон после первой покупки и больше не двигается; отсюда мой неверный вывод «однородный источник ничего не даст». А байты банка двигает **WHAT**`dst` каждого терма, и она **draft-side**: складывается из банкнот, которые модель уже выдала (`mining.go:109`), **то есть растёт с каждой покупкой ДАЖЕ при полностью однородном источнике**. Я меняла ТЕКСТ глав, а надо было менять то, что ПРЕДЛАГАЮТ ЧЕРНОВИКИ.
**Причина 2, из-за которой попытка (5) не могла сработать в принципе, — спойлер-гейт `since_ch`** (`membank/memory.go:640-647`, жёсткое `chapter < since_ch`). Вводя терм поздними главами, я выталкивала его `since_ch` за пределы уже доставленных — и он не попал бы в их инъекцию НИКОГДА, какой бы `dst` потом ни получил. ⚠ **Условия тянут в РАЗНЫЕ стороны:** естественный способ заставить дельту расти (поздние главы вводят новый терм) ровно этим и убивает достижимость. Дефекту нужна ОБРАТНАЯ асимметрия: терм присутствует в РАННЕМ исходнике, а его рендеринг приходит поздно — то есть асимметрия ПОВЕДЕНИЯ МОДЕЛИ, а не текста.
**Пере-проверила зондом приёмки у себя:** `方源 dst="" since_ch=3``dst=Фан Юань since_ch=3`, банк двинулся, а `content_hash` главы 1 `MOVED=false`, вызовов ровно два. То есть мой прогон был верен, а гипотеза о причине — нет.
**Диагностика на будущее, две строки:** дампить авто-банк между покупками и смотреть у терма ДВА поля — что `dst` перешёл из пустого в непустой **и** что `since_ch` ≤ номера уже доставленной главы. Второго я не проверяла.
**Сценарный тест теперь есть** — `TestAMidRunBankMoveNeverBillsBeyondTheGrant` (рецепт приёмки: четыре главы, побайтово одинаковые, различаются только банкнотами). **Я пере-проверила его двумя своими посадками, а не приняла на слово:** снятие вызова пере-плана падает структурным отказом; снятие вызова И гарда воспроизводит дефект целиком — **4 вызова вместо 2, $0.007280 вместо $0.003640, обе уже купленные главы пере-отредактированы, и отчёт зовёт их «2 rode along at $0»**.
**Оба пина стоят рядом и не заменяют друг друга** (различение оркестратора, точнее моего): инвариантный утверждает «пере-план вызывается», сценарный — «покупатель не платит второй раз», а платит покупатель именно за второе.
**Жёсткое равенство `== 2×грант` оставлено намеренно:** гейт терминолога в фикстуре выключен явно, счёт детерминирован, и равенство ловит не только перерасход, но и молчаливую ПОТЕРЮ оплаченной работы.
**Условие достижимости УЗКОЕ, и это записано с обеих сторон:** нужна конъюнкция трёх условий (терм на WHICH-списке · его `dst` приходит поздно · `since_ch` покрывает уже доставленную главу). В фикстуре приёмки два терма из трёх её не выполняют и остаются инертными. Оценку «покупка №50 переоплачивает 490 юнитов» автор находки признал ПОТОЛКОМ ТЯЖЕСТИ, когда сработало, а не ожидаемым случаем; частоту на реальной книге никто не мерил. На диспозицию не влияет — лечение структурное и покрывает класс независимо от узости входа.
**2. ⛔ Флагнутый юнит объявлялся доставленным — ВЫЛЕЧЕНА.** Допуск решается ДО работы (этим и ограничиваются деньги), но решение ЗАПЛАТИТЬ за юнит — не факт существования главы: юнит может вернуться флагнутым и не отгрузить ничего. Отчёт печатал «2 NEW unit(s) delivered» поверх одного читаемого юнита. **Лечение: счётчики выравниваются ПОСЛЕ волн, по фактическим исходам** (`reconcile`), и заведён отдельный счётчик «оплачено, но флагнуто» с прямой формулировкой: деньги потрачены, текста нет, покупкой не чинится — это `redrive`. Тест + мутация.
**6. Подсказка обещала то, чего грант не сделает — ВЫЛЕЧЕНА.** `rebill_output_units` подавался как «та единица, в которой считает `--max-units`» — правда, и в одиночку вводит в заблуждение: грант идёт СПЕРВА в недоставленное, поэтому на книге, где оно есть, `--max-units N` доставит новое и не пере-сделает ничего. Оговорка была в коде, но не там, где читает оператор. Теперь в подсказке. Тест.
**3 и 4 — ПОДТВЕРЖДАЮ, НЕ вылечила, и говорю почему.** Обе об одном: ось «свежий/пере-делка» выведена из ПОЛНОТЫ СТРОК, а не из факта отгрузки. Добавление стадии делает доставленную книгу «никогда не доставлявшейся» (#3); юнит, прерванный между волнами, второй раз считается свежим и тратит слот повторно (#4). **Правильный носитель у движка уже есть — реестр анонсов (`events_outbox.once_key`, `unitOnceKey` = книга+волна+юнит), то есть собственное определение «этот юнит был отгружен читателю», монотонное на всю жизнь книги.** Но читающего метода у стора нет (есть только `EnqueueOnce`, который ПИШЕТ), а `internal/store` я не трогала ни строкой за весь пак и не считаю правильным открывать его в хвосте четвёртого круга. **Это отдельный заказ на одну функцию чтения + перевод оси на неё.**
**ПОПРАВКА к моей же формулировке, внесена оркестратором при приёмке и принята.** Я написала «деньги целы: ошибается слово, а не потолок». Слишком мягко — **слово тут ПРОДАЁТ.** Отчёт, называющий дочитанную книгу «3 unit(s) NEVER delivered», через строку CLI приглашает их купить, и покупатель платит за уже полученное. Деньги целы В ДВИЖКЕ — потолок держит, счёт вызовов верен, — но не в том, что увидит платформа и предъявит покупателю. Диспозиция не меняется (предмет отдельный, `internal/store` в хвосте пака не открываю), но цена ошибки — покупательская, а не редакторская, и в строке заказа стоит именно так.
**5 — цифру принимаю, договорённость в силе, но она теперь важнее.** Терминолог переигрывается каждой покупкой: три покупки по 1 юниту дали три полнокнижных консолидации по `usd=0.005460` каждая, при том что сам юнит дешевле. **Накладные масштабируются КНИГОЙ, а не грантом** — книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. Мы согласились, что место этой траты — в цене; при мелкой нарезке продажи она перестаёт быть накладной и становится основной статьёй, и цена обязана это знать.
#### Сверка по буллетам промта — что закрыто и чем
**§3.2, «родня 124(в)» — заказано сказать ПРЯМО, говорю:** да, моя форма закрывает и `resnapshot --dry-run`. `status` считает ре-билл при ЛЮБОМ дрейфе, не только банковом (`status.go`: проекция идёт при наличии строк, а не под `ConfigDrift`), поэтому после починки свёртки он И ЕСТЬ «оценить пере-проход, ничего не покупая» — ровно та способность, ради которой строка 124(в) поименована будущей дверью. **Отдельного глагола `resnapshot --dry-run` заводить не нужно.** Это удешевляет проект на один глагол и один документ.
**§3.2, «ключей не требует» — закрыто структурно, а не тестом, и вот почему это правильно:** `models.CheckKeys` вызывается ТОЛЬКО под `if forWrite` (`runner.go:260-266`), а `status`/`export` идут через `NewReadOnlyRunner`. Своего глагола пак не завёл, поэтому пин `keysfile_test.go`, перечисляющий читающие глаголы, покрывает мой путь без изменений — и он зелёный (6 тестов). Новый тест здесь ничего бы не доказал сверх пина.
**§5 ось 2 («пара, которой в репо нет») — проверено:** в `volume.go` и `bankmaterialize.go` ветвлений по паре/книге нет. Единственное упоминание языка — `lang.TargetChecksFor(r.Book.TargetLang)`, и это ДАННЫЕ: лукап по встроенным файлам `target-<lang>.txt` (`lang/embedded.go:106-118`), а не свитч. Строка перенесена из `seedGlossary` дословно, новой пар-логики пак не вносит. Пара, которой в репо нет, работает добавлением файла данных.
**§5 ось 3 (детерминизм) — была заявлена без доказательства, теперь доказана:** `TestTheVolumeCeilingMovesNoRequestByte` — снапшоты обеих волн не двигаются от установки потолка, и КАЖДОЕ тело запроса обрезанного прогона встречается среди тел безпотолочного (в том числе когда потолок отдаёт юниты ВНЕ порядка книги). Мутация «подмешать `MaxUnits` в снапшот» падает с точным сообщением. Это несущее: сдвинь потолок снапшот — и передача флага пере-покупала бы всю уже оплаченную книгу.
**§3.3, соседи — называю строкой, не чиню:**
**`redrive` зовёт `TranslateBook` по ВСЕЙ книге** (`status.go:926`): своя денежная неожиданность, была до пака. Флаг `--max-units` я ему сознательно не дал, отказ несёт настоящую причину.
**мёртвая переменная `manualSrcs`** в прежнем `seedGlossary` (писалась, не читалась никем) — исчезла при выносе гathera; ничего с ней не потерялось, проверено грепом по старой версии.
**джоб-гард не отличает банк-онли-движение от смены конфига** (`stagerun.go:47-59`): он и есть корень хвоста про майнящие книги. Ратифицированный Р6-контур, чужой предмет — не трогала.
#### Диспозиция ВСЕХ находок ревью — 25 сырых, ни одна не потеряна
Сведено из журналов двух ревью-воркфлоу (10 + 15 сырых находок) плюс трёх опус-ратификаций. Каждая строка — либо ВЫЛЕЧЕНА (с тестом и мутацией), либо ОТКЛОНЕНА с доводом, либо НАЗВАНА и НЕ вылечена с указанием, почему и чей это предмет. Пустых клеток нет намеренно: находка без диспозиции — это находка, которую следующая сессия найдёт заново.
| # | Находка | Диспозиция |
|---|---|---|
| 1 | Отказ `--max-units` учил ЛОЖНОМУ факту про `redrive` | ✅ свой текст с настоящей причиной + тест, проверяющий, что причина не врёт про адресата |
| 2 | Рефактор `seedGlossary` терял замечания падающего прогона | ✅ лог ДО обработки ошибки; тест + мутация |
| 3 | Мульти-членный юнит не покрыт ни одной фикстурой | ✅ мелкая нарезка (3 чанка в главе); мутация «допускать лидера» падает |
| 4 | Ветка «хеш не воспроизведён ВООБЩЕ» не покрыта | ✅ `TestAnUnreproducibleHashIsNeverFree`, 6 состояний |
| 5 | Тест тождества сравнивал только черновую волну | ✅ прогон ПЕРЕД сравнением + ассерт покрытия ОБЕИХ волн |
| 6 | ⛔ Перерасход: флагнутый член без edit-строки считался бесплатным | ✅ `unitFullyRecorded` вместо вердикта `resolveChunkState`; репро-тест + мутация |
| 7 | ⛔ Вторая покупка на МАЙНЯЩЕЙ книге падает `exit 1` | ⚠ **НЕ вылечено, названо.** Корень — джоб-гард (`stagerun.go:47-59`), ратифицированный Р6-контур, чужой предмет. Мною добавлен громкий WARN + тест на него; проводка гейчена оркестратором |
| 8 | Трата стопа майнинга/терминолога вне потолка и переигрывается | ⚠ **НЕ вылечено, названо.** Резать её объёмом семантически неверно (не единица доставки); место — в ЦЕНЕ, платформенная сторона |
| 9 | Форма конвейера, где «юнит» — два объекта | ✅ `planVolume` отказывает громко; тест + мутация |
| 10 | `rebill_basis: failed` рядом с посчитанной цифрой | ✅ решение вынесено в чистую `rebillOutcome`; табличный тест на 5 состояний + мутация |
| 11 | ⛔ Потолок тратил покупку на переделку вперёд доставки | ✅ доставка-вперёд; ратифицировано опусом (порядок безопасен, A/B 6/6 против 4/6); тест + мутация на ТРОПУ ГНИЕНИЯ |
| 12 | Объёмный стоп не доезжает машинным каналом | ⚠ **НЕ вылечено, и это РАТИФИЦИРОВАННАЯ отсрочка:** промт §3.1 сам поместил различение в отчёт/лог, а не в поток; словарь потока — зона оркестратора, он сказал «не сейчас, строкой». Названо вопросом B |
| 13 | Ветка `proj.Rows == 0` не покрыта | ✅ `TestARunThatRePaysNothingIsNotAskedForConsent`; мутация падает |
| 14 | `export` не покрыт после правки банка | ✅ решено ОТКАТОМ export + пином осознанного расхождения |
| 15 | Нет теста на проводку `--max-units` через CLI | ✅ `TestTranslateWiresMaxUnits`; мутация «убрать проводку» падает |
| 16 | Предупреждение про майнящие книги не покрыто | ✅ тест на обе стороны (есть потолок / нет потолка) + мутация |
| 17,20 | Смешанное происхождение цифр в отказе гейта | ✅ каждое число из своей проекции; скоупная оговорка несёт скоупный ре-пин |
| 18 | Тройное повторение самого дорогого расчёта | ✅ мемо со сроком жизни БАНКА; запинена ИНВАЛИДАЦИЯ (корректность), не скорость |
| 19 | Капнутое согласие — дыра? | ✅ **РАТИФИЦИРОВАНО ЗДОРОВЫМ** опусом (согласие, не обход); по его же рекомендации запинено РАСКРЫТИЕ, которое этим согласием и делает |
| 21 | ⛔ Строка стопа сообщала ЮНИТЫ как ГЛАВЫ | ✅ движок говорит только «units»; тесты пере-пинены на верную формулировку |
| 22 | Отчёт называл несуществующий тест и снятые поля | ✅ таблица и §9 исправлены; отозванный пункт помечен ОТОЗВАННЫМ, а не переписан молча |
| 23 | Денominация: `rebill_units` (chunk×stage) против `--max-units` (юниты) | ✅ **РАТИФИЦИРОВАНО ДЕФЕКТОМ** опусом (замерено 15 против 6); аддитивное `rebill_output_units`, `rebill_units` НЕ переименован; тест + мутация |
| 24 | Семантика `export.ConfigDrift` изменена «за компанию» | ✅ ОТКАЧЕНО: чужое потребляемое поле не пере-определяется побочным эффектом; расхождение запинено, чтобы не «починили» обратно |
| 25 | Один документ `status` описывал ДВА банка | ✅ материализация помнит свои строки; счёт неподписанных — по тому же банку |
**Итого: 22 вылечены с тестом и мутацией, 3 названы и не вылечены** (7, 8, 12) — у каждой указан предмет и чей он.
#### ⚠ Мой рефактор сломал ШЕСТЬ упоминаний в чужом доке — я насчитала два и ошиблась в адресе
**ИСПРАВЛЕНО ПО ПРИЁМКЕ 29.08: первая редакция этого раздела была неверна дважды, и обе ошибки — метода, а не арифметики.** Оставляю разбор, а не переписываю молча.
**Ошибка 1 — счёт. Я насчитала ДВА сломанных якоря; сломано было ШЕСТЬ упоминаний** (те же два плюс четыре про `AttachRubyAliasesToManual`). Причина: я взяла число из `counts.py --lint`, **который по собственному объявлению content-сверяет только якоря С ТОКЕНОМ** («без токена — несверяемы: 728»). Эту его слепоту я в этой же сессии сама и процитировала — и всё равно приняла его счёт за полный. Правильный инструмент был на расстоянии одного грепа: `grep -n "seeding\.go"` по доку.
**Ошибка 2 — адрес. Я разрешала якорь по НОМЕРУ СТРОКИ, а надо по СОДЕРЖИМОМУ.** В старом файле на `:157` действительно лежала материализация базового банка, и я записала переезд туда. Но док в строке 76 говорил не о ней, а о `InjectivityCollisions` — warning-only диагностике, которая **никуда из `seeding.go` не уезжала**, а просто съехала со `157` на `64`. На `bankmaterialize.go:326`, куда я её отправила, лежит `r.baseMemory = membank.MaterializeBank(baseIn, …)` — к этому якорю отношения не имеющее. Номер совпал, предмет — нет.
**Верная таблица (сверено содержимым, каждая строка проверена грепом в дереве):**
| Что говорил док | Было | Стало |
|---|---|---|
| `InjectVoice is FALSE…` — условный фолд voice/address | `seeding.go:130-133` | `bankmaterialize.go:291-294` (текст перенесён ДОСЛОВНО) |
| `InjectivityCollisions` — warning-only диагностика dst-коллизий | `seeding.go:157` | **`seeding.go:64`** — из файла НЕ уезжала |
| `AttachRubyAliasesToManual`×4 упоминания | `seeding.go:43-50`, `:47` | `bankmaterialize.go:88-94`, сам вызов на `:92` |
Правку в доке делал оркестратор — `docs/architecture/` не моя зона. Мой вклад здесь: сломала я, а нашла и починила приёмка, потому что я доверилась инструменту вместо грепа и номеру вместо содержимого. **Урок годен шире этого случая: якорь разрешается тем, ЧТО он утверждает, а не тем, на какой строке лежал.**
#### §8. Obstacle — что НЕ удалось и что НЕ проверено
**0. ГЛАВНОЕ, и это НЕ вылечено: объёмный потолок на книге, которая МАЙНИТ банк, ломает вторую покупку.** Найдено денежной линзой, **воспроизведено дважды независимо** (линзой и опровергателем, каждым на своей копии репозитория, с контролем).
Механика: обрезанная покупка черновит часть книги, а стоп майнинга получает НЕобрезанный список чанков (`waverun.go:154` передаёт весь `chunks`) и досыпает `dst` по мере появления новых банкнот. Значит авто-банк РАСТЁТ от покупки к покупке; следующая покупка сворачивает его в обогащённый банк, edit-снапшот съезжает, а edit-джобы ПРЕДЫДУЩЕЙ покупки к нему пришпилены. Джоб-гард (`stagerun.go:47-59`) банк-онли-движение не отличает от смены конфига и падает **нетипизированной** ошибкой ⇒ **exit 1, платформа пишет `failed`** — вместо задуманного «завершение, код 0». Контроль (без `--max-units`, книга целиком за один прогон, потом резюм) проходит чисто — то есть путь открывает именно обрезание.
**Поправка опровергателя, важная и снижающая цену:** `--resnapshot` пере-оплату НЕ вызывает в общем случае — после подъёма снапшота джоба работает обычный ре-пин по `content_hash`, и уже отредактированные главы возвращаются **за $0**; платит только та глава, в тексте которой новый терм реально встречается.
**ПОПРАВКА к моей же оценке, найдена оркестратором при приёмке и проверена мной по коду — я ЗАНИЗИЛА область.** Я написала «платформа `--resnapshot` уже передаёт, флагманский путь цел, бьёт по ручному вызову». Неверно: платформа передаёт его НЕ всегда, а под условием `book.BankMoved` (`platform/internal/runs/runs.go:288`, читал не правил), и этот флаг ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит (`platform/internal/pgstore/books.go:985-993`: «a correction landed»). Значит бьёт именно по ФЛАГМАНСКОМУ пути: майнящая книга, вторая покупка, правок не было ⇒ флага нет ⇒ гард ⇒ `failed`. **Решение оркестратора: механизм лендится, но ПРОВОДКА `--max-units` на платформе гейчена до лечения.** **Смягчение, которое я сделал:** `planVolume` при живом майнинге пишет громкий WARN, называя и эффект, и `--resnapshot`. **Чего я НЕ делал:** не трогал джоб-гард — это ратифицированный Р6-контур и та самая мина D39.165 §3 / эрраты 28.08-и, её лечение назначено платформенной половине. **Заказывай отдельно, если хочешь, чтобы банк-онли-движение джоб-гард пропускал сам.**
**1. Стоп майнинга и терминолог тратят ВНЕ объёмного потолка — и переплачиваются каждой покупкой.** Тоже подтверждено. Мой пункт был мягче («потолок режет юниты, книго-уровневые вызовы — только деньги»); линза дожала: кандидатное пространство терминолога зависит от набора уже отчерновленных чанков, поэтому между покупками оно МЕНЯЕТСЯ, request_hash другой, и чекпойнты не переигрываются за $0. Книга, проданная десятью покупками, платит **десять** книжных консолидаций там, где цельный прогон платит одну. Ограничивают это только `gates.terminology.budget_usd` и `--ceiling-usd`. **Не чинил:** резать терминолога объёмом семантически неверно (он не единица доставки), а переносить его за пределы обрезанного прогона — это пере-проектирование майнинга (родня строки 203(з)). **Обязан быть в цене продажи, если книга майнит.**
2. **Живого прогона с провайдером НЕТ.** Всё исполнение — против фейкового провайдера батареи. Ни одного платного вызова я не делал и не был на это уполномочен. Значит: цифры «2 вызова на юнит» и «$0.00182 за юнит» — свойства фикстуры, а не рынка; поведение потолка на реальном материале не мерено.
3. **Прогон, обрезанный по объёму и упёршийся в подписной стоп банка, уходит с кодом 3**, и `res.Volume` тогда не докладывается: остановка принадлежит банку, а не потолку. Поведение корректное, но пара «стоп банка + объёмный потолок» отдельным тестом НЕ покрыта.
4. **Платформенная половина не сделана и не заказана мне** (§0 промта): `--max-units` в argv никто не передаёт. Пока платформа не научится считать `главы → юниты` по манифесту и передавать флаг, обещание «купил N глав — получил N глав» НЕ выполнено. Механизм есть, трубы нет.
5. **`storeOrder` доказан на моей фикстуре, а не на всех входах.** Тождество опирается на: `UNIQUE (book_id, src, sense, since_ch, until_ch)` (`store/migrate.go:203`) ⇒ префикс ключа не даёт ничьей, и на отсутствие `COLLATE` в схеме ⇒ побайтное сравнение TEXT. Оба факта проверены грепом и чтением DDL. Чего я НЕ проверял: поведение при значениях с невалидным UTF-8 и при типовой аффинности SQLite, если в TEXT-колонку когда-нибудь ляжет число.
6. **Порог согласия (`rebill_consent_usd`) я НЕ трогал** — сужена только СУММА. Если владелец считает, что при объёмной покупке должен ужиматься и порог, это отдельное решение.
7. Батарея прогнана на `make battery`; **`make battery-stand` (корпусные тесты) НЕ гонял** — нет стендовых данных на этой машине.
#### §9. Вопросы оркестратору — пингом, не интерпретацией в свою пользу
1. ⛔ **Продавать ли объёмом книги, которые МАЙНЯТ банк — до отдельного заказа?** Obstacle-0: вторая покупка на такой книге падает `exit 1`/`failed` без `--resnapshot`, и это открывает именно обрезание (контроль без потолка проходит чисто). Платформа `--resnapshot` уже передаёт (D39.166), значит флагманский путь цел, но ручной вызов — нет, и цена покупки на майнящей книге включает лишнюю книжную консолидацию терминолога (Obstacle-1). **Мой совет:** либо не ставить `--max-units` на майнящих книгах до заказа по джоб-гарду, либо принять оба хвоста явно. Решать тебе — я поставил громкий WARN и не стал трогать ратифицированный Р6-контур.
2. **Форма §3.2 отличается от той, что назвала строка 231.** Строка предписывает «движковый глагол ЛИБО флаг». Я не сделал ни того, ни другого: я починил СВЁРТКУ, потому что слепое окно было не в том, что `status` пишущий, а в том, что он сворачивал последний прогон вместо ближайшего. Это дешевле обоих кандидатов и ближе к уже ратифицированной двери D39.165 §3 («платформа читает смету из УЖЕ существующего `status --json`»). **Нужна ратификация формы и правка формулировки строки 231.**
3. **Поля шва.** `rebill_units`/`rebill_usd` потеряли `omitempty`, добавлено `rebill_basis`; `status_version` оставлен `tm-status-v1` (правка АДДИТИВНАЯ, потребителей у полей сейчас нет — они сняты с аллоулиста). Строка 231 обещает их возврат «вместе с глаголом» — глагола нет, значит возвращаются **вместе с этим фиксом**. Нужен контрактный минор и ре-аллоулист трёх полей. ⚠ **Уточнение от платформенной сессии `textmachine-c0`, сверено ею по коду:** пара снята с аллоулиста паком P10 **по ТАЙМИНГУ** (`platform/internal/ingest/resync.go:37-43`) — `status` проецировал СОХРАНЁННУЮ память, поэтому сразу после apply честно отдавал ноль, — а не «за отсутствием потребителя», как я написала сначала. Это ровно та причина, которую мой §3.2 и снимает. Риска для шва при этом нет: `DecodeStatus` (`resync.go:76`) — обычный `json.Unmarshal` без `DisallowUnknownFields`, поэтому новое поле её сторона просто не заметит. Возврат — слово оркестратора, он берёт минор на себя (пойдёт вместе с `session_ended`, канон 0.8.0).
4. ⛔ **ЭТОТ ПУНКТ ОТОЗВАН МНОЮ ЖЕ — подтверждать его НЕ надо.** Здесь стояла просьба подтвердить сужение СУММЫ гейта D20.2-Q2 объёмным скоупом с доводом «инертно без флага». Довод верный и НЕважный: с флагом каллер сам выбирает размер прогона, поэтому дробление обходило порог и пере-оплачивало книгу целиком, ни разу не спросив согласия (замерено: 30 глав, четыре прохода по $0.003640 при пороге $0.005460). **Действующее решение — второй круг, находка 1:** порог судится по ВСЕЙ книге; именованный кап `--accept-rebill=X` — по тому, что заплатит ЭТОТ прогон; прогон, не пере-оплачивающий ничего, гейта не встречает. Подтверждать надо ЭТО.
5. **`redrive` я флаг НЕ дал** (отказ по правилу «команда, которая на флаг не действует, его отвергает»). Соседняя правда, которую называю строкой, а не чиню: `redrive` внутри зовёт `TranslateBook` по ВСЕЙ книге (`status.go:926`), то есть у него своя денежная неожиданность — она была и до пака.
6. **Сигнал объёмного стопа в ПОТОК событий я не завёл.** По правилу `runevents.go:29-40` новый тип события — это минор, и читатель незнакомые типы игнорирует; но это твоя зона и контрактный минор, а §3.3 его в пак не клал. Если он нужен платформе — заказывай отдельно.
#### Вопросы оркестратору после второго круга — новые и изменившиеся
**A. Разблокировка майнящих книг — есть дешёвый вариант, но решение продуктовое, поэтому НЕ беру.** Причина поломки второй покупки в том, что стоп майнинга досыпает авто-банк между покупками. Если на прогоне с `--max-units` стоп майнинга НЕ запускать вовсе, авто-банк не растёт, edit-снапшот не двигается, вторая покупка идёт чисто, и хвост №1 исчезает без правки ратифицированного джоб-гарда. Цена ровно одна и она честная: **книга, продаваемая по частям, не майнит банк, пока её продают** — майнинг случается на цельном прогоне. Это размен «продавать по главам ИЛИ майнить», а не техническая деталь, поэтому его выбираешь ты. Если скажешь «да» — это несколько строк и тест.
**B. Различение доставки и переделки сейчас ЧИТАЕМО ЧЕЛОВЕКОМ, но не машиной.** `VolumeStop` несёт `Delivered`/`Reworked`/`LeftFresh`/`LeftRework`, и CLI это печатает — но платформа читает поток событий и код выхода, а туда различение не идёт. Значит покупка десяти глав, целиком ушедшая в переделку, для платформы по-прежнему выглядит как успешная доставка десяти. Полное лечение — поле в терминальном кадре потока, то есть твой контрактный минор (ты уже сказал «не сейчас, строкой»). Пишу это ещё раз потому, что после второго круга это перестало быть косметикой: именно здесь пере-оплата всё ещё может продаться как доставка, просто теперь молча на проводе, а не громко в отчёте.
**C. Порог согласия остаётся КНИЖНЫМ, и это теперь несущее решение, а не умолчание.** После отзыва сужения порог судит всю пере-оплату книги. Следствие, которое надо принять явно: **любая** покупка через `--max-units` на сдвинутом банке встречает гейт и требует `--accept-rebill`. Платформа его передаёт (при `BankMoved`), так что путь рабочий, но «тихих» пере-проходов больше не будет ни одного — и это ровно то, чего требует Р6. Если владелец захочет, чтобы мелкие покупки проходили без вопроса, это НЕ порог надо двигать, а заводить учёт уже-данного согласия — отдельный механизм, отдельный заказ.
**D. Проводка `--max-units` гейчена тобой — я это поддерживаю и после второго круга поддерживаю сильнее.** Пока различение доставки и переделки не доезжает до платформы машинным каналом (пункт B), платформа не может отличить «купил 10 глав и получил 10» от «купил 10 глав и получил 10 переделок». Механизм готов, труба — нет, и открывать её раньше пункта B я бы не стала.
#### Дополнение к вопросам после третьего круга
**E. Три развилки закрыты РАТИФИКАЦИЕЙ, а не моим словом — фиксирую, чтобы ты не пере-решал их заново.**
— «капнутое согласие» — ЗДОРОВО (согласие, а не обход; раскрытие теперь запинено);
— «денominация» — был ДЕФЕКТ, вылечен аддитивным `rebill_output_units`;
— «доставка вперёд переделки» — ЗДОРОВО, порядок безопасен (байты вне-очередного юнита дословно совпали с прямым прогоном), выигрыш замерен A/B (6/6 против 4/6).
Если пере-решаешь — пере-решай явно: под каждым лежит исполнение, а не рассуждение.
**F. Аллоулист: полей теперь ЧЕТЫРЕ, и четвёртое обязательно.** К `rebill_units`/`rebill_usd`/`rebill_basis` добавилось **`rebill_output_units`**. ⚠ **Возвращать `rebill_units` БЕЗ него нельзя**: `rebill_units` считает `chunk×stage`, все прочие «units» документа — выходные юниты, отношение внутри одной книги непостоянно, и платформа, отмерившая пере-проход по `rebill_units` и передавшая это число в `--max-units`, купит кратно больше книги. Ратификатор измерил: `rebill_units: 15` двумя строками под `total_units: 6`.
**G. Про умолчание «доставка вперёд переделки» — прошу зафиксировать словом, а не молчанием.** Покупка, ЦЕЛЬЮ которой был пере-проход, теперь потратит грант на недоставленные главы, если они есть. Ратификатор показал, что дверь пере-прохода этим не ломается (на дочитанной книге доставка-вперёд И ЕСТЬ порядок книги), но на ЧАСТИЧНО доставленной книге разница видна. Если продукту нужен режим «только переделывать» — это флаг, и это твой заказ, не мой.
> ⚠ **Прожитые дампы паков «писатель книги» и «деньги» ВЫНЕСЕНЫ 30.08** в
> [archive/PROGRESS-2026-08-backend.md](archive/PROGRESS-2026-08-backend.md) (103 КБ, четверть файла): обе
> записки-плана и оба итога залендены и ратифицированы, решения живут телами D39.175 и D39.170. Читать
> НЕ надо — там доказательная база, а не состояние.
## Полигон

View file

@ -621,3 +621,494 @@ sha256-самопроверку (эталон второй правки сним
*(Закрытые записи 2531.07 — пак-18 · мини-прогон · фикс-пак · сквозная проверка · хендофф 26.07 · ресёрч общности фаза 1 — в [archive/PROGRESS-2026-07-25-31.md](archive/PROGRESS-2026-07-25-31.md).)*
## ⟶ Вынесено 30.08 оркестратором №20: прожитые дампы паков «писатель книги» (D39.175) и «деньги» (D39.170)
> Оба пака ПРИНЯТЫ и ЗАЛЕНДЖЕНЫ, их решения и числа живут телами нот. Здесь — исходные записки-планы и
> итоги сессий целиком, как доказательная база. ⚠ Инструкции отсюда НЕ исполнять.
### 📝 ЗАПИСКА-ПЛАН — пак «писатель книги» (промт `docs/archive/prompts/BACKEND_BOOKWRITER_PACK_SESSION_PROMPT_2026-08-30.md`, строка 236), сессия `textmachine-main-fc`, 30.08
**Что беру (зона `backend/`, HEAD на старте `67c05be`, дерево чисто).**
1. Прод-пакет `backend/internal/bookfile` (только stdlib — `archive/zip`, `encoding/xml`; без `import "testing"`):
`container.go` — OCF-контейнер (`mimetype` первым и `zip.Store`, `container.xml`, запись entry в порядке вызовов,
нулевые таймметки) — `chunktest/epub.go` переводится на него, его OPF-заглушка и XHTML-декой остаются тестовыми;
`epub.go` — EPUB 3 из нейтральной модели `Book{Identifier, Title, Language, Modified, Description,
Chapters[]{Title, Paragraphs[]}}`: `dc:identifier`/`dc:title`/`dc:language`/`dcterms:modified`, nav
(`properties="nav"`, ⛔ НЕ в spine), один XHTML на главу, `<h1>` = заголовок главы, `<p>` = абзац;
`txt.go` — чистый текст из той же модели (заголовок строкой, абзацы через пустую строку, главы через
пустую строку; ни баннеров, ни флагов, ни `gapMarker`).
2. Сборка из `BookExport``backend/internal/pipeline/bookbuild.go`: `Runner.BuildBook(formats, partial)`
`Export(false)` → дыры → отказ либо файл. Запись `writeFileAtomic` рядом с ProjectDB
(`<project_db>.book.<fmt>`, как `.manifest.json`/`.bank.json`), путь публикуется в
`StatusArtifacts.BookFiles` (`book_files`: map формат→путь, аддитивно) — приор промта принят: платформа
пути не выводит сама (17 §1). Существующий файл — перезапись: производный детерминированный артефакт, как
манифест; бэкап отказывает потому, что он точка восстановления, а файл книги пересобирается из стора.
3. Аддитивные поля `tm-export-v1` (форма не меняется, все `omitempty`): `ChunkExport.Heading` (`heading`) —
литерал `u.Members[0].Heading` в момент сборки записи (и у pending-юнита тоже, он из манифеста);
`BookExport.GhostUnits` (`ghost_units`: `[{chapter,chunk_idx}]`) — ключи ghost-строк, чтобы маркер стоял
в главе, а не только в счётчике; `BookExport.TextModified` (`text_modified`, `CCYY-MM-DDThh:mm:ssZ`) —
max(`chunk_status.updated_at`) по финальным строкам, вошедшим в файл (колонка `NOT NULL DEFAULT
datetime('now')`, `store/migrate.go:37`; в `ChunkStatus` дочитывается доп. полем). Это и есть
`dcterms:modified`: последнее изменение отгруженного текста — факт стора, не часов процесса.
4. Глагол `tmctl build --config b.yaml [--format epub,txt] [--out path] [--partial]` (по умолчанию оба формата;
`--out` — только при одном формате). Stdout — JSON-конверт `tm-build-v1` (пути, счётчики, `complete`).
Новый глагол, а не флаг `export`: `export` — чистое чтение в stdout, писать файлы из него — смешать
аудит-проекцию с артефактом; `manifest` — прецедент глагола-писателя сайдкара. `dispatchCommands` и
usage-строка расширяются — контрактное расширение того же рода, что `bank-apply` (тест
`TestParseNoArgsUsage` сам перечисляет такие расширения); чужим глаголам новые флаги ОТКАЗЫВАЮТСЯ
(дисциплина `--keys-file`/`--max-units`).
5. Отказ — новый `pipeline.RefusalBookIncomplete = "book_incomplete"` → exit **16** (полоса 1019; таблица
тотальна по `TestTheRefusalTableIsTotal`); сообщение перечисляет дыры `глава/юнит/причина`.
6. Читательские слова — ДАННЫЕ: `<langpack_root>/<target_lang>/reader.txt` (`key<TAB>value`, плейсхолдеры
`{chapter} {unit} {dropped} {holes} {total} {ghost}`), читает ОТДЕЛЬНЫЙ загрузчик `lang.LoadReaderWords`;
в `Pack.Version()` НЕ фолдится (пин тестом «наличие reader.txt не меняет Version()» + `current_snapshot`
minirun до/после). Ключ — язык ЦЕЛИ, не пара: слова читает читатель целевого языка, и они не зависят от
исходного. Поставляю `backend/configs/langpacks/ru/reader.txt`. Каталог `<tgt>/` загрузчик пака не
читает (`Load` — только `<src>/` и `<pair>/`, `langpack.go:215-330`), так что канал вне фолда по
построению, а не по соглашению.
**Политика §3.2 — по умолчанию ОТКАЗ, `--partial` — файл с пометкой; довод:**
- Дыры: pending · withheld (`Disposition!="pending" && FinalText==""`) · incomplete (`DroppedMembers>0` при
непустом тексте) — арифметика `render.go:573-587` выносится в общий хелпер `pipeline`, которым пользуется и
`renderExport` (вывод `--plaintext` побайтово прежний) · **ghost — тоже дыра**: при укрупнении нарезки
строка-лидер несёт текст ПРЕЖНЕГО, более короткого юнита, а остаток источника лежит в ghost-строках и в
файл не попадает (acceptance: 57 строк → 37 юнитов, 20 ghost при `pending 0` — ровно «молча полная»
книга). Позиция ghost внутри главы неизвестна → маркер в конце главы с числом потерянных строк.
- Default = отказ: D29.1(б) — reader-facing выдача fail-closed; дверь платформы получит `--partial` для
контрактного «may be exported, finished or not» (14 §createExport).
- `--partial`: пометка неполноты — первый абзац первого документа spine (то, что читатель видит, открыв
книгу) + `dc:description`; маркер — отдельный абзац на месте КАЖДОЙ дыры (pending/withheld — вместо текста
юнита; incomplete — перед текстом юнита, как `gapMarker`; ghost — в конце главы). Форма — из `reader.txt`;
без пака (acceptance, `runner.go:346`) — несловесная: `⚠ {chapter}.{unit}` у дыры, `⚠ {holes}/{total}` в
пометке (символ + числа, никакого словаря в Go). В `FinalText`/`export.go` маркер не попадает.
- drift — НЕ дыра: текст файла побайтово текст прогона (`export.go`, комментарий к `exportConfigDrift`);
попадает в JSON-конверт `build` и в WARN, в файл — нет («config drift» — операторский словарь).
Следствие §4 заявляю сразу: чистого бездрейфового файла на реальной книге на этом HEAD НЕТ — чистый
путь доказывается синтетикой, реальные книги — посадки на дыры.
- Оглавление и `<h1>` при `Heading==""` — номер главы цифрами (пустая ссылка nav невалидна; цифры —
единственный лейбл, который не решает открытый К-3 «какой лейбл видит читатель у главы без заголовка»;
словесный шаблон в данных — 1 строка после ратификации, вопрос владельцу в отчёте). При `Heading!=""`
срез известного префикса `heading+"\n\n"` из `FinalText` (`chunker.go:186-192`), `<h1>` = Heading.
- Абзац = непустая строка `FinalText`. Замерено на `minirun/export.json`: юнит 1/0 — 14 разделителей `\n\n`
и 7 одиночных `\n`, юнит 2/0 — 56 и 0: редактор чередует их без семантики, различать = выдумать
различие; строка — единица абзаца исходника-вебновеллы. Круг §4 сравнивает абзацы ингеста с абзацами,
которые положил писатель.
- Метаданные: `dc:title` = `config.Book.Title` как есть (на стенде `蛊真人` — ограничение в отчёт), `dc:language`
= `TargetLang`, `dc:identifier` = `urn:textmachine:book:<book_id>`, `dc:creator` нет (источника нет).
- 0 юнитов — отказ `sourceHasNoContent` (spine без элементов невалиден); 1 юнит — обычная книга.
**Что НЕ беру:** платформу и дверь `createExport`/`getExport`; лестницу D25.1 в коде и в `BookExport`;
строки 160/201 (`ApplyHeading` и три точки склейки не трогаю; модельные заголовки в прозе не извлекаю и не
правлю); `--resnapshot` и любые платные вызовы; `langpack_root` в копию acceptance; направление письма
(`dir`/`page-progression-direction`) — в `internal/lang` данных о направлении нет (`lang-script.txt`
только скрипты), RTL-цели в репо нет → вопрос в отчёт, не литерал; CSS/обложку; новые зависимости.
**Чем докажу:** герметично — `bookfile` (mimetype первым/stored, nav не в spine, детерминизм двух сборок,
экранирование, 0/1 глава), `pipeline` (круг `IngestEncoded` равенством абзацев на прогоне fake-провайдера;
pending через потолок; withheld через санитайзер; **ghost — усадка исходника после прогона (посадки в
тестах не было, строю)**; drift — бамп `prompt_version`; синтетический `BookExport` на incomplete), `cmd/tmctl`
(incomplete бинарём через `setupGapProject`; парс флагов; exit 16 через totality-тест), `lang` (парс
`reader.txt`; `Version()` не меняется от его наличия). На копиях книг в песочнице с переписанными путями:
minirun — оракул июля 14/14 по `final_text/snapshot_id/disposition`, `current_snapshot` до/после
`reader.txt`, `cmp` двух ПРОЦЕССОВ, epubcheck 5.3.0 `0 fatal / 0 error`, круг ингестом; acceptance — отказ с
перечнем и `--partial`-файл с несловесными маркерами; coldrun-a — отказ 14/14 pending;
`git -C books status --short -- gu-zhenren` до/после одинаков (снят до старта: 6 строк `D …-wal/-shm`).
`make battery` + `-race`; в середине — субагент-ревью готовой части против критериев; в конце —
адверсариальный проход по направлениям §4.
### ✅ ИТОГ — пак «писатель книги» (промт `docs/archive/prompts/BACKEND_BOOKWRITER_PACK_SESSION_PROMPT_2026-08-30.md`, строка 236), сессия `textmachine-main-fc`, 30.08
**Что построено (зона `backend/`, поверх `67c05be`; НЕ закоммичено — дерево на лендинг оркестратору; `git status`: 10 изменённых + 8 новых путей в `backend/`, плюс этот журнал).**
- `internal/bookfile/` (новый прод-пакет, только stdlib, без `testing`): `container.go` — OCF-контейнер (`mimetype` первым, `Store`, через `CreateRaw` — без data-descriptor, флаги `0x0000`; `container.xml`; entry в порядке вызовов; нулевые zip-таймметки); `epub.go` — EPUB 3 (`dc:identifier`+`unique-identifier`, `dc:title`, `dc:language`, `dcterms:modified`, nav `properties="nav"` ⛔ не в spine, XHTML на главу: `<h1>` + `<p>`); `txt.go` — чистый текст; `model.go` — модель `Book/Chapter`, `Blocks(i)` (единственное определение содержимого документа главы), `CleanText` (общая чистка XML-недопустимых символов для обоих форматов), экранирование.
- `internal/chunk/chunktest/epub.go` переведён на `bookfile.NewContainer`; тест-декои (OPF-заглушка, `<title>c</title><style>`) остались тестовыми; 9 импортёров зелёные без правки ассертов.
- `internal/lang/reader.go``LoadReaderWords(root, targetLang)`: `<langpack_root>/<target>/reader.txt`, читается ОТДЕЛЬНО от `Load``Pack.Version()` не фолдится по построению — `Load` читает только `<src>/` и `<pair>/`); `DefaultReaderWords()` — несловесная форма (символ + числа; тест пинит отсутствие букв). Данные: `configs/langpacks/ru/reader.txt` (7 ключей: `hole.{pending,withheld,incomplete,stale,ghost}`, `notice.{holes,ghost}`).
- `internal/pipeline/holes.go` — ОДИН предикат дыры `UnitHole`/`HoleCounts` (порядок = `renderExport`); `render.go` считает шапку `--plaintext` через него (вывод побайтово прежний).
- `internal/pipeline/export.go` — аддитивно (`omitempty`): `ChunkExport.Heading` (`heading` — литерал `u.Members[0].Heading`, у pending тоже), `BookExport.GhostUnits` (`ghost_units`, ключи ghost-строк, сортированы), `BookExport.TextModified` (`text_modified` — max `chunk_status.updated_at` по ФИНАЛЬНЫМ строкам, вошедшим в экспорт); `exportConfigDrift`: строка со стадией, которой нет в текущем конфиге, — drift (находка 10 ниже). `store/chunkstatus.go` дочитывает `updated_at` в `ChunkStatus.UpdatedAt` (колонка `NOT NULL DEFAULT datetime('now')`, `migrate.go:37`; миграции — только `ADD COLUMN`: у minirun `text_modified` = `2026-07-25T19:17:53Z`, время боевого прогона, не миграции 30.08).
- `internal/pipeline/bookbuild.go``Runner.BuildBook(BuildOptions{Formats, Out, Partial})`: `Export(false)``staleUnits``assembleBook` (чистая функция, возвращает список дыр) → отказ `RefusalBookIncomplete` (`refusal.go`, exit **16**) либо стейдж всех форматов + общий коммит в `<project_db>.book.<fmt>` (не запрошенные соседи удаляются — рядом с БД лежит ОДНА сборка); `StatusArtifacts.BookFiles` (`book_files`, map формат→абс. путь; в `status --json` и `manifest --json`). `--out` — только НОВЫЙ файл (существующий путь любого рода, несуществующий каталог → `config_invalid`, exit 10). Сбой между двумя `rename``write_incomplete` (exit 15) с перечнем landed/not landed.
- `cmd/tmctl`: глагол `build --config b.yaml [--format epub,txt] [--out path] [--partial]`; stdout — JSON `tm-build-v1` (`files`, `total/pending/withheld/incomplete/stale_units`, `stale_unknown`, `ghost_rows`, `config_drift`, `complete`, `text_modified`); чужим глаголам новые флаги отказываются, `build` отказывает `--plaintext/--pairs/--json`; usage-строка расширена (`invocation_test.go` — контрактное расширение по собственному комментарию теста «Every one is a deliberate contract extension»; это ЕДИНСТВЕННАЯ правка существующего теста). `backend/README.md` — строка `cmd/tmctl` дополнена `build`.
**Политика (§3.2) как реализована — ШЕСТЬ состояний, не пять:** дыры — pending · withheld (`Disposition!="pending" && FinalText==""`, плюс юнит, чей текст после чистки не даёт ни одного абзаца) · incomplete (`DroppedMembers>0`) · **stale** (финальная строка сделана под исходник, которого в книге уже нет: `content_hash` строки ≠ `cachedRenderedContentHashes` — тот же факт, по которому резюм перекупает юнит; считается ТОЛЬКО без дрейфа, под дрейфом `stale_unknown: true`, никогда не «нет») · **ghost** (при укрупнении нарезки строка-лидер несёт текст прежнего, более короткого юнита; остаток лежит в ghost-строках и в файл не попадает: acceptance 57→37, 20 строк при `pending 0`). По умолчанию — ОТКАЗ при любой дыре с перечнем `chapter N unit M: <вид> (<причина>)` и ghost-ключами (≤50 строк); `--partial` — файл с пометкой (первые абзацы первого документа spine + `dc:description`; в txt — после заголовка книги) и маркером на месте КАЖДОЙ дыры (pending/withheld/stale — вместо текста; incomplete — перед текстом; ghost — в конце главы, счётчиком). drift — НЕ дыра (текст файла = текст прогона): в JSON-конверте и WARN, в файле нет. Оглавление/`<h1>` при `Heading==""` — номер главы цифрами. Абзац — непустая строка `FinalText` (`\r\n`/`\r`/`\n`). `dc:title``book.yaml` как есть (на стенде `蛊真人`), `dc:language` = `target_lang` (форма тега BCP 47 проверяется, иначе `config_invalid`), `dc:identifier` = `urn:textmachine:book:<book_id>`, автора нет.
**Сверка состава против §3 (пункт → сделано → чем доказано; все команды — на копиях в песочнице `<SB>/stand`, бинарь `go build -o <SB>/stand/tmctl ./cmd/tmctl`).**
| §3 | Сделано | Доказательство |
|---|---|---|
| 3.1 EPUB + чистый txt из `BookExport`, ОДИН файл на формат, из `tmctl` | да: `tmctl build``<project_db>.book.epub` / `.book.txt` | `build-minirun.json`: 2 пути, `complete: true`; `java -jar epubcheck-5.3.0/epubcheck.jar <book>.epub``0 fatals / 0 errors / 0 warnings / 0 infos` на всех трёх EPUB (minirun, acceptance `--partial`, coldrun-a `--partial`) |
| 3.1 без аудит-баннеров/флагов/`gapMarker` в txt | да | `TestTXTShape`, `TestBuildBookRoundTrips…` (нет `=== CHAPTER`, `TEXT MISSING`, `pending`, `flagged`); `TestBuildRefusesTheGapAndPartialMarksIt` — реальный c-lite прогон бинарём: `⚠ 1.0 1` перед уцелевшим текстом, ни `INCOMPLETE`, ни `cjk_artifact` |
| 3.1 `--plaintext` не тронут | да | `cmp` вывода бинаря из `git archive HEAD` и нового: IDENTICAL на minirun (171932 б), acceptance (354821 б), coldrun-a (1077 б) |
| 3.1 основа — `chunktest/epub.go`, OCF в прод без `testing` | да | `TestEPUBFixtureMimetypeIsOCFConformant` и 9 импортёров зелёные без правки; `TestContainerIsOCFConformant` (флаги `0`, extra `0`, `mimetype` на фиксированных смещениях) |
| 3.1 детерминизм двумя ПРОЦЕССАМИ | да | `cmp first/<f> <copy>/<f>` — IDENTICAL для 6 файлов (две сборки в двух процессах); zip-таймметки `1980-00-00 00:00`; sha256 EPUB minirun `084ff0a9…`, совпал с независимой сборкой ревьюера |
| 3.2 пять (шесть) состояний, отказ по умолчанию / `--partial` | да | acceptance: `build` → exit 16 «pending=0 withheld=1 incomplete=1 stale=0 ghost_rows=20 of total_units=37 (stale: UNKNOWN …) · chapter 1 unit 0: withheld (cjk_artifact) · chapter 10 unit 0: incomplete — 1 member(s)… (length) · ghost rows: 1/1 1/3 …»; `--partial``⚠ 2/37`, `⚠ +20`, `⚠ 1.0`, `⚠ 10.0 1`, `⚠ 10 +1`…; coldrun-a: exit 16 (14 pending) / `--partial` со словесными маркерами из `reader.txt`; minirun (только drift): записан по умолчанию, `config_drift: true`, `stale_unknown: true`, в файле `⚠` нет; тесты `TestBuildBookRefusesAPending…`, `…WithheldUnitIsAHole`, `…GhostRowsAreAHole` (посадка построена: усадка исходника после прогона), `…StaleUnitsAreAHole`, `…DriftIsReportedNotMarked`, `…RemovedStageIsDrift`, `TestAssembleBookMarksEveryHoleKind` |
| 3.2 не в снапшот | да | `current_snapshot` minirun с `ru/reader.txt` и без — `bb0b3b2aaf0d…` оба раза (копия корня пака в песочнице); `TestReaderWordsDoNotMoveThePackVersion` |
| 3.2 книга без langpack — несловесно | да | acceptance (`langpack_root` не задан): `⚠ 1.0`, `⚠ 10.0 1`, `⚠ 2/37`; `TestReaderWordsAbsentIsTheNonVerbalForm` |
| 3.2 маркер не в `FinalText`/`export.go` | да | JSON HEAD vs new: `existing equal True`, добавлены только `text_modified`, `heading`, `ghost_units` |
| 3.3 `ApplyHeading` и три точки не тронуты; срез только известного префикса | да | `git diff HEAD -- backend/internal/chunk/chunker.go backend/internal/chunk/ingest.go backend/internal/pipeline/waverun.go` пуст; вызов `ApplyHeading` в `export.go` — контекстная строка; minirun: 10 заголовков срезаны, круг 402/402; `TestAssembleBookMarksEveryHoleKind` (упоминание заголовка в тексте не срезается) |
| 3.3 `Heading==""` → номер цифрами в nav и `<h1>` | да | acceptance nav `1``25`; minirun/coldrun-a — `Глава N` из `heading` |
| 3.3-бис пять требований EPUB 3; nav не в spine; `dc:language` из `TargetLang` | да | `TestEPUBCarriesTheFiveRequiredThingsAndKeepsNavOutOfTheSpine`; OPF minirun: `urn:textmachine:book:guzhenren-minirun`, `蛊真人`, `ru`, `2026-07-25T19:17:53Z`; epubcheck |
| 3.4 место/атомарность/конверт/глагол/коды | да | `status --json`/`manifest --json` копии minirun: `book_files: {epub, txt}`; `TestTheRefusalTableIsTotal`, `TestDispatchCommandsCoversTheSwitch`, `TestParseNoArgsUsage`; `TestBuildBookLeavesOneBuildBesideTheDatabase`; `TestBuildBookOutNamesANewFile` |
| 3.5 платформа не тронута; `tm-export-v1` аддитивно; пары — данные; без зависимостей | да | `git status` — только `backend/` и `docs/PROGRESS.md`; `go.mod` не менялся; греп читательских слов по новому Go-коду — только комментарии |
| §4 `make battery` (`-race`) | да, 2 настоящих прогона | `battery-1.log` (до правок ревью) и `battery-3.log` (после всех правок): `BATTERY_EXIT=0`, 18 пакетов, скипы — три прежних стендовых (`TestMinerFullBookParity`, `TestHelperEventsRun`, `TestHelperKillLoop`). ⚠ «Батарея №2» была ложным прогоном: `make` из корня репо (`No rule to make target`), мой `echo` замаскировал код — поймано по логу, перезапущено из `backend/` |
| §4 дифф `^func Test` | ни один не исчез | `git diff HEAD -- '*_test.go' \| grep '^-func Test'` пуст; `func Test` в `backend/`: 1017 → 1044 (+27) |
| §4 круг ингестом равенством | да | `tmroundtrip` (собран `go build -overlay`, файла в дереве нет): minirun 10/10 глав, 402 абзаца; acceptance `--partial` 25/25, 1568; coldrun-a `--partial` 10/10, 25 |
| §4 `git -C books status` до/после | см. obstacle | 6 строк `D …-wal/-shm` до; после — 4: у acceptance `-wal`/`-shm` ПОЯВИЛИСЬ (идентичны трекнутым; БД не изменена) |
| §4 хирургия конфигов в копиях | список | minirun/coldrun-a `book.yaml`: удалены `mined_delta:`/`mined_rejects:`; acceptance `pipeline-acceptance.yaml`: удалены `stm_depth: 2`, `overlap_tokens: 200`, оба `prompt:`; добавлен `acceptance/pairs/zh-ru.yaml` (копия minirun/pairs с `prompts_root` на `backend/prompts` этого дерева); все абсолютные пути переписаны на песочницу/этот ворктри, греп по копиям пуст; миграции v10/v7/v14 → v16, `backups/` — в песочнице |
| §4 промежуточное и финальное ревью субагентом | да, Fable 5 ×2 | ниже |
**Промежуточное ревью (Fable 5, исполнением) — 8 находок, все закрыты:** (1) MAJOR: `--format`-подмножество оставляло соседний формат прежней сборки рядом с БД при равноправном `book_files` → стейдж всех форматов + общий коммит, не запрошенные соседи удаляются, пин `TestBuildBookLeavesOneBuildBesideTheDatabase`; (2) `text_modified` считался по всем строкам всех стадий → только по финальным строкам экспорта (acceptance `20:41:47Z``20:40:00Z`, coldrun-a — эпоха: ничего не отгружено); (3) пустой `title` → exit 1 → `config_invalid` с именем ключа, то же для `target_lang` не в форме BCP 47; (4) `mimetype` с data-descriptor → `CreateRaw`; (5) control-символы срезались только в EPUB → `CleanText` один раз для обоих; (6) `--format epub,epub` → дедуп; (7) `backend/README.md` без `build` → дополнено; таблицы exit-кодов в `docs/` — пинг; (8) подтверждено верным: `UnitHole` = арифметика `renderExport`, `DispOK` с пустым текстом недостижим, нулевые таймметки, `reader.txt` вне `Load`.
**Финальный адверсариальный проход (Fable 5; overlay-тесты, свой бинарь, epubcheck на adversarial-книгах: краевые символы, RTL, 2000 глав) — 10 находок:**
1. MAJOR — правка исходника НА МЕСТЕ (ключи `(chapter, chunk_idx)` уцелели, текст под ними сменился) давала файл, побайтово равный прежнему, под `complete: true`. **Исправлено — шестое состояние `stale`** (см. политику; пин `TestBuildBookStaleUnitsAreAHole`: правка главы 2 → `chapter 2 unit 0: stale`, глава 1 не задета; после `translate` — снова whole). ⚠ Побочный факт: `projectRebill` сверяет content-hash ТОЛЬКО при сдвинутом снапшоте (bank-only ветка), при том же снапшоте строка идёт как «$0 resume» без сверки — проекция денег `status` слепа к правке исходника на месте, `translate` при этом перекупает (ревьюер воспроизвёл: «2 units re-bought»). Не моя строка — пинг.
2. MAJOR — `--out` перезаписывал что угодно, включая БД проекта («file is not a database») и исходник. **Исправлено:** `--out` только для НОВОГО файла; существующий путь / отсутствующий каталог → `config_invalid`; место рядом с БД по-прежнему перезаписывается. Пин `TestBuildBookOutNamesANewFile` (БД, исходник, чужой файл, несуществующий каталог; ни одного `.tmp-` после). Стенд: `--out <project_db>` → exit 10, БД цела.
3. MINOR — сбой на фазе коммита второго формата оставлял смешанную сборку и exit 1. **Исправлено:** `write_incomplete` (exit 15) с перечнем landed/not landed; комментарий файла говорит правду. Тестом не покрыто («не проверено»: сбой `rename` в только что записанном каталоге без инъекции не воспроизводится).
4. MINOR — юнит с непустым `FinalText`, дающий ноль абзацев (control-символы/`U+FFFF`), не был дырой. **Исправлено:** сборщик читает юнит по тому, что тот реально положил (ноль абзацев → withheld); счётчики отчёта — из списка дыр сборщика; `HoleCounts` остался ровно арифметикой `renderExport`.
5. MINOR — голый `\r`: txt нёс его, XML нормализует → форматы расходились. **Исправлено:** `\r\n`/`\r`/`\n` — конец строки.
6. MINOR — `--out` в несуществующий каталог → exit 1. **Исправлено:** `config_invalid`.
7. NIT — `build` молча принимал `--plaintext/--pairs/--json`. **Исправлено:** отказ.
8. NIT — `dc:title` с паддингом vs обрезанный txt. **Исправлено:** `TrimSpace` в модели и EPUB.
9. NIT — ghost-маркер по номеру главы СТАРОЙ нарезки может встать в соседнюю главу при усадке. **Не исправлено, задокументировано** (`assembleBook`): книга всё равно помечена неполной, ложна лишь координата; иного источника координаты у ghost-строки нет.
10. MINOR (унаследовано `Export`) — удаление стадии `edit` делало черновики «полной» книгой без дрейфа. **Исправлено в `exportConfigDrift`:** строка со стадией, которой нет в конфиге, — drift; под ним stale = unknown; пин `TestBuildBookRemovedStageIsDrift`. ⚠ `Status` (`checkWave`) несёт то же слепое пятно — пинг.
Подтверждено проходом как верное: все посадки A (withheld при `pending 0`, ghost-only, исчезнувшая глава, все юниты withheld, 0 юнитов, `HoleCounts` = шапка `renderExport` на всей матрице состояний); детерминизм (нет `time.Now`/`rand`/`os.Stat`/map-итерации в байты; 2 процесса под `TZ=Asia/Tokyo LANG=C` — идентично); заголовки (срез только известного префикса; pending-первый юнит с заголовком получает заголовок; пустой заголовок недостижим — `parseHeading`); nav вне spine; круг на трёх стендовых книгах воспроизведён независимо + epubcheck чист на adversarial-книгах; RTL — валиден, без `dir`, честно неподдержан; 2000 глав — 47 мс/707 КБ, ingest 38 мс; ids/hrefs по индексу; словесных литералов в Go нет; пин `Version()` подлинный; exit 16 в полосе, таблица тотальна; JSON аддитивен; `ApplyHeading` и три точки не тронуты; ни один тест не удалён; линтер 0.
**Obstacle reporting — что НЕ удалось и что осталось непроверенным.**
- **Не проверено — открывается ли в читалке.** Читалки на машине нет; «валиден» ≠ «открылся». Проверено ТОЛЬКО валидатором W3C (epubcheck 5.3.0, `0/0/0` ×3) и кругом через собственный ингест движка.
- **Не проверено — чистый бездрейфовый файл на реальной книге.** Недостижим на этом HEAD без платного перепрогона (все три копии под `config_drift: true`); чистый путь доказан синтетикой (`TestBuildBookRoundTripsThroughIngestAndPublishesItsPaths`: `complete: true`, `stale_unknown: false`, без пометок). Следствие: **stale-проверка на реальных книгах стенда не сделана** (`stale_unknown: true` у всех трёх — под дрейфом факт неразличим), доказана только харнесом.
- **Не проверено — сбой между двумя `rename`** (находка 3): ветка `write_incomplete` написана, не исполнена.
- **Не проверено — посадка на incomplete в `pipeline`-харнесе:** реальный member-drop только через бинарь (gap-харнесс `cmd/tmctl`) и acceptance (10/0, `length`); в `internal/pipeline` — синтетический `BookExport`.
- **Не проверено — направление письма.** `dir`/`page-progression-direction` не выставляются; данных о направлении в `internal/lang` нет; RTL-файл валиден, но выровнен слева — честно неподдержано (вопрос 4).
- **Не объяснено — read-only открытие ОРИГИНАЛЬНОЙ acceptance-БД.** `git -C books status --short -- gu-zhenren` до: 6 строк `D …-wal/-shm`; после: 4 — у `acceptance/guzhenren-acc-a.db` `-wal` (0 байт, 13:47:36 +03) и `-shm` (32 КБ, 13:47:49 +03) появились, идентичны трекнутым (`git diff` пуст), сама БД (mtime 24.08) не изменена. Мои команды все шли по копиям (`project_db`/`source_file` копии — в песочнице, проверено грепом; `.lock` копии — от миграции 12:56); тесты дерева acceptance-БД не открывают (греп); субагент-ревьюер завершился раньше этого времени. В `ListAgents` живы ещё три сессии (оркестратор и две, вероятно, полигонные) — кто-то из них открыл оригинал read-only. Файлы НЕ трогал (чужой репозиторий).
- **Опровергнуто (промтом):** «глава 1 minirun остаётся `""` даже с правилом» — на HEAD у minirun заголовок у всех 10 глав (`headings 10`, nav `Глава 1…Глава 10`); `""` у всех 25 глав acceptance — подтверждено.
- **Подтверждено:** числа промта §4 (minirun 14/14 ok · drift · оракул июля 14/14 по `final_text/snapshot_id/disposition`; acceptance 37 юнитов · ghost 20 · withheld 1/0 · incomplete 10/0 · drift; coldrun-a 14/14 pending · drift); миграции v10/v7/v14 → v16; epubcheck приносится и работает.
- **Ограничения формы (по промту, не исправлялись):** `dc:title` = `蛊真人` (перевод не выдумывается); автора нет; `text_modified` — «последняя ЗАПИСЬ» финальной строки ($0-резюм, переписавший строку без изменений, двигает штамп; между сборками детерминизм сохраняется); побайтовый детерминизм гарантирован для ОДНОГО бинаря (другой toolchain Go — иной deflate-поток при тех же документах); ghost-координата — старой нарезки (находка 9); `stale` под дрейфом — «неизвестно».
- **Самопойманная ошибка:** ложный прогон «батареи №2» (см. таблицу).
- **Эхо §7:** правок задания релеем владельца не поступало; единственное сообщение оркестратора (подтверждение адресата + напоминания) заказом не является; приор «артефакт рядом с ProjectDB + `StatusArtifacts`» принят с доводом.
**Вопросы владельцу (гейт между этим паком и дверью платформы, D25.1):**
1. **Лестница `run_complete / structurally_complete / quality_reviewed / publishable`:** писатель различает ровно «без дыр» (`complete: true`) и «с дырами» (отказ / `--partial` с пометкой). Что из лестницы печатать на титуле и в имени файла и какая ступень даёт копию БЕЗ пометки — решение владельца; в код не внесено намеренно.
2. **Лейбл главы без заголовка (К-3):** сейчас — номер цифрами. Словесный шаблон — одна строка в `reader.txt`, если ратифицируется; не сделано, чтобы не решать К-3 данными.
3. **Ghost и stale как дыры по умолчанию:** довод — оба дают «молча полную» книгу (acceptance: 20 строк не в файле при `pending 0`; правка исходника на месте: старый перевод под `complete: true`). Если владелец хочет «предупреждение, не отказ», — по строке в `BuildBook`.
4. **Направление письма для RTL-цели:** поле данных цели (в `reader.txt` или рядом), не Go — когда появится RTL-пара.
**Пинги оркестратору (чужие зоны, не правил):** (а) exit-код **16 `book_incomplete`** в таблицы `docs/glossary.md:64` и `docs/architecture/14-api-contract/README.md:731,758`; (б) `docs/architecture/09-target-architecture.md`: слой «писатель книги» построен; следующий шаг — дверь платформы `createExport`/`getExport` + `export_formats` из `bookfile.Formats`; дверь обязана СТРОИТЬ (`tmctl build --format <f> [--partial]`) и отдавать то, что построила, — не читать файл, лежащий рядом с БД (копия прежней сборки); (в) `projectRebill`/`status` слепы к правке исходника на месте при том же снапшоте (перекупка без предупреждения о деньгах) — строка бэклога; (г) `Status.checkWave` не видит исчезнувшую стадию (то же, что находка 10 у `Export`) — строка бэклога; (д) строки 160/201 не тронуты, `Heading` в `tm-export-v1` — тот же временный рендер (D39.122 п.2д), помечено в комментарии поля.
### 📝 ЗАПИСКА-ПЛАН — пак «деньги» (промт `docs/archive/prompts/BACKEND_MONEY_PACK_SESSION_PROMPT_2026-08-29.md`, D39.165 §1б + строка 231), сессия `textmachine-e4`, 28.08
**Скоуп:** `backend/` — §3.1 потолок ОБЪЁМА рядом с денежным · §3.2 смета пере-прохода без покупки. `platform/` не трогаю (проводка `CeilingChapters` — не мой заказ, честная граница §0 промта принята).
**§3.1 — В ЧЁМ МЕРИТЬ: выходной юнит (`ManifestUnit`), кандидат (а). Обоснование.**
Ровно эта гранулярность и есть то, что движок отгружает и что публикует манифест: `ManifestUnit` строится итерацией по `r.outputUnits(chunks)` (`backend/internal/pipeline/manifest.go:330-357`), то есть по тем же `editUnit`, которыми идёт волна редактуры; `units_total` манифеста — то самое поле, из которого интейк платформы пишет `chapters.units_total` (`platform/internal/pgstore/readmodel.go:113,166-175`, читал не правил). Значит перевод «N глав → M юнитов» у платформы ТОЧНЫЙ, а не оценочный: манифест снимается `tmctl manifest` за $0 и без ключей (`runner.go:239-244``models.CheckKeys` только `if forWrite`) ДО первой оплаты.
**Кандидат (в), `chunk×stage`, отвергнут именно по ловушке, названной в промте.** Это биллинговая единица (`rebill.go:322`), и в ней «10 единиц» — разный объём книги при разной форме конвейера: на юнит приходится `len(Members)*nDraftStages + nEditStages` позиций (`rebill.go:207`). Продавая (а) и каппя (в), мы воспроизвели бы дефект §0 слоем ниже. Кандидат (б), чанк, — внутренний артефакт нарезки, покупателю не виден и манифестом не считается.
**Честная оговорка, которую обязан назвать:** число юнитов книги зависит от ФОРМЫ конвейера — редакторский конвейер даёт крупные `editUnit`, черновой-только даёт по юниту на чанк (`waverun.go:67-76`). Это свойство конфигурации, а не ветвление по паре; но манифест и потолок обязаны сниматься с ОДНОГО деплоя, иначе «10 юнитов» значат разное.
**§3.1 — КАК СЧИТАЮТСЯ РЕПИНЫ И РЕТРАИ (прямой ответ на вопрос промта).**
- **Ретрай и эскалационный хоп — НЕ считаются.** Они внутри юнита: ретрай — итерация цикла попыток (`stagerun.go:158-194`), эскалация — отдельный `runAttempt` (`escalation.go`), но `chunk_status` на `chunk×stage` пишется ОДНА (`stagerun.go:297-308`). Довод по существу: объёмный потолок ограничивает ДОСТАВКУ, денежный — ТРАТУ. Хвост качества (1.5%→23% по D39.165 §1) — факт денежный, и у него уже есть свой потолок; если бы ретраи жгли объём, купивший 10 глав получал бы 8, потому что две главы заикнулись, — то есть платил бы КНИГОЙ за качественный хвост движка. Это ровно та подмена, которую пак и лечит.
- **Репины и $0-резюмы — НЕ считаются.** Иначе пере-проход, пере-привязывающий 500 юнитов за $0 (`rebill.go:315-320`), сжёг бы потолок, не доставив ничего.
- ⇒ **Правило одной строкой: потолок считает выходные юниты, по которым ЭТОТ прогон сделает хотя бы один провайдерский вызов.**
**§3.1 — МЕХАНИЗМ: пре-флайт скоуп, а не счётчик в воркере.**
Допуск считается ОДИН раз ДО волн, по состоянию стора, и даёт множество юнитов-в-работе; волны просто пропускают то, что вне скоупа. Предикат «$0-обслуживаемый» берётся из УЖЕ существующего — того же, которым считает `projectRebill`/`repin`, — чтобы объёмный стоп и денежная смета не могли разойтись двумя определениями.
**Почему не счётчик со слотами в воркере (рассмотрено и отвергнуто):** волна параллельна (`runWave`, `waverun.go:236-309`), и при отказе в слоте конкурентно розданные элементы пропадали бы НАВСЕГДА — при `workers=8` и потолке 1 семь юнитов оказались бы пропущены, а освободившийся после $0-репина слот уже некому было бы отдать. Пре-флайт эту гонку растворяет.
**Грабля LiteLLM исполнена конструктивно:** ни один элемент вне скоупа не НАЧИНАЕТСЯ, поэтому пере-бой на один вызов невозможен в принципе, а не по проверке.
**Грабля OpenHands исполнена:** остановка называет свой потолок — в `BookResult`, в строке лога и в выводе CLI.
**§3.1 — ФОРМА ОТВЕТА ЧЕРЕЗ ШОВ: беру решение оркестратора и ПОДТВЕРЖДАЮ его исполнением.**
Код выхода 0, замороженный словарь (`main.go:115-140`) не трогается. **Проверил то, о чём он просил пингнуть, — не ломает:** exit 0 у платформы = статус ПРОГОНА `ready` (`platform/internal/runs/reconcile.go:703-706`, читал не правил), а не «книга дочитана»; полоса книги едет событиями юнитов, поэтому дочитанной книга не объявляется и следующую покупку не блокирует.
**Словарь потока НЕ трогаю в этом паке.** Новое значение `Finished.Outcome` было бы минором по правилу `runevents.go:29-40`, но у платформы неизвестный ВЫХОДНОЙ код читается как отказ (`ingest/exit.go:168-169`), и множить развилки в чужом пакете без контрактного минора — не мой заказ (§3.3). Различение живёт в отчёте и логе, как и решил оркестратор.
**Ортогональность цела:** `--ceiling-usd` остаётся кумулятивным книжным капом и судит в `Reserve` (`ledger.go:56-72`); объёмный потолок леджера не касается вовсе и живёт до волн. Денег не считает, холдов платформы не двигает.
**§3.2 — ФОРМА: чинится СВЁРТКА, а не заводится глагол. Это дешевле всех трёх кандидатов промта.**
Слепое окно — не в том, что `status` пишущий, а в том, что он сворачивает НЕ ТО: `projectStoredMemory` (`status.go:815-826`) материализует СОХРАНЁННЫЙ глоссарий, то есть фолд ПРОШЛОГО прогона, тогда как ближайший `translate` пере-сеет банк из ФАЙЛОВ (`seedGlossary`) и сделает это ДО гейта согласия (`bookrun.go`: `seedGlossary` → … → `checkRebillConsent`). **Собственное оправдание комментария — «exactly as it does for `translate` itself» — неверно: `translate` правку файла как раз ВИДИТ.** Значит сегодня `status` НЕДО-считывает то, что `translate` реально выставит, и это денежный дефект, а не отсутствующая функция.
**Лечение:** фолд тех же входов, что берёт `seedGlossary` (сид + рубя + мined-дельта + авто-банк), — **В ПАМЯТИ, без единой записи**. `status` при этом остаётся ЧИСТО ЧИТАЮЩИМ: он читает файлы вместо стора, а фолд не пишет никуда. Возражение оркестратора против «флага у `status`» снимается тем, что оно стояло на посылке «свёртка идёт через стор» — она не обязана.
**Записи не будет и не может быть:** `status` открывает стор через `NewReadOnlyRunner``OpenReadOnly` с `PRAGMA query_only=1` (`store/store.go:117-166`), и на состоянии §4.3 (прогон СОСТОЯЛСЯ ⇒ база есть) фолбэк на `Open` не срабатывает вовсе — записей НОЛЬ, даже first-touch.
**Родня 124(в) закрывается тем же:** `status` уже считает ре-билл при ЛЮБОМ дрейфе, не только банковом, — то есть после фикса он и есть «оценить пере-проход, ничего не покупая», ради которого поименован `resnapshot --dry-run`.
**Третий кандидат (`bank-apply --dry-run`) рассмотрен ПЕРВЫМ, как заказано, и отклонён с доводом:** он требует `--decisions`, а в продуктовом потоке D39.165 §3 решения к моменту продажи УЖЕ на диске — документа больше нет. Плюс он Runner-free и не держит ни статусов, ни манифеста, ни конфига (`bankdecisions.go:294-314`), то есть под смету пришлось бы втащить в него весь Runner. Ту же проекцию он сможет позвать позже — она будет одной функцией.
**§3.2 — ТОЖДЕСТВО БЕСПЛАТНОЙ СМЕТЫ И ПЛАТНОГО ПРОГОНА: доказываю, а не предполагаю.**
Канонический фолд идёт через стор и там ПЕРЕ-СОРТИРУЕТСЯ: `ORDER BY src, sense, since_ch, until_ch, status, dst` (`store/glossary.go:223`). Порядок несущий дважды — `MaterializeBank` порядок входа НЕ трогает (`membank/memory.go:283-346`), а дальше он определяет и хеш версии (`ComputeVersionScopedIn` стримит SHA-256 по строкам в порядке итерации), и ЛИНИИ отрендеренного блока через стабильную сортировку бюджета (`memory.go:608-619`), которую режет токенный бюджет.
**Почему тождество ДОКАЗУЕМО, а не правдоподобно:** таблица несёт `UNIQUE (book_id, src, sense, since_ch, until_ch)` (`store/migrate.go:203`), поэтому префикс из четырёх колонок НЕ МОЖЕТ дать ничью — порядок ТОТАЛЬНЫЙ и полностью определён значениями, а не rowid и не порядком вставки. `COLLATE` в схеме нет нигде ⇒ сравнение текста побайтное, как и `<` в Go. Алиасы читаются `ORDER BY term_id, alias`, внутри записи это сортировка по `alias` (`glossary.go:247-249`). `glossary.id` в хеш не входит намеренно (`memory.go:360-363`).
⇒ Фолд в памяти обязан: собрать тот же набор значений + отсортировать по (Src, Sense, SinceCh, UntilCh, Status, Dst) + отсортировать алиасы + то же для voices/pairs по их собственным ключам.
**Проверка — ДИФФЕРЕНЦИАЛЬНАЯ, исполнением:** тест гоняет оба пути на одном состоянии и требует совпадения построчно (кроме `ID`), совпадения `Version()`/`BaseVersion()`, совпадения отрендеренных байтов и совпадения самого числа `projectRebill`. Ассерт равенства, а не «похоже».
**Пишущий путь НЕ трогаю:** порядок ВСТАВКИ в `seedGlossary` остаётся как есть, иначе поехали бы `glossary.id` и телеметрия `injected_ids`. Добавляется чистая функция упорядочения для читающей стороны.
**Находки по дороге (в блате пака, называю сразу):**
1. ⚠ **`projectRebill` в `status` рендерит по НУЛЕВОМУ базовому банку.** `projectStoredMemory` ставит только `r.memory`, `r.baseMemory` не ставит НИКОГДА (единственные присваивания — `seeding.go:157,159`), а ветка репина берёт именно его (`rebill.go:167`) и молча пропускает позиции при `nil` (`repin.go:130`). Следствие: при движении БАЗОВЫХ строк банка `status` считает всю черновую волну пере-оплатой, то есть расходится с `translate` — вопреки собственному обещанию `status.go:206` «status can never quote a different number».
2. ⚠ **При `snapshot_drift` у `status` память вообще не материализуется:** `projectStoredMemory` зовётся под `if !rep.SnapshotDrift` (`status.go:635`), а `projectRebill` — безусловно при наличии строк (`status.go:673`), и `memoryVersion()` при `nil` отдаёт хеш ПУСТОГО банка (`snapshot.go:190-195`) ⇒ книга объявляется пере-оплаченной целиком.
3. ⚠ **Мина `omitempty` подтверждена исполнением чтения:** три РАЗНЫХ состояния — «не запускалось», «посчитано и ноль», «расчёт упал» (`status.go:668-681` логирует и оставляет нули) — на проводе неотличимы. Это ровно цена D39.166 п.2.
Первые две мой фикс §3.2 снимает по построению (фолд считается безусловно и ставит ОБА банка); третью закрываю формой поля.
**НЕ делаю:** калибровку $0.03 (строка 202) · цену от объёма исходника · строки 228/230/141/131 · словарь кодов выхода · словарь потока событий · `platform/` · коммиты. Незакоммиченную работу полигона не касаюсь.
### ✅ ИТОГ — пак «деньги» (промт `docs/archive/prompts/BACKEND_MONEY_PACK_SESSION_PROMPT_2026-08-29.md`, D39.165 §1б + строка 231), сессия `textmachine-e4`, 29.08
**Батарея зоны:** `make battery`**21 пакет из 21, 0 FAIL**, линтер **0 issues**, `test -race` зелёный целиком.
**Проверяю ПОЛНОТОЙ СПИСКА, а не отсутствием `FAIL`, и это правило стоило мне двух ложных выводов.** Первый «зелёный» прогон был на 20 пакетов из 21 с `FAIL` в хвосте, который обёртка подала как `exit code 0`. Сверка: `comm -23 <(go list ./...) <(grep -E "^(ok|\?)" <лог>)` — пусто.
**И про `internal/store`.** Он дважды ронял батарею (`TestTheSeamIsNotChargedToTheStoreOperationBudget`, `apply migration 14: context deadline exceeded`), и я сначала списала это на память — **неверно.** Проверила как надо: развернула ЧИСТЫЙ HEAD через `git archive` и погоняла тот же тест в обоих деревьях вперемежку — **моё 4/5 падений, HEAD 4/5**, то есть от моих правок не зависит. Тест ужимает `opTimeout` до 150 мс (`migrate_test.go:408-429`) и под параллельной нагрузкой не укладывается; на разгруженной машине проходит 3/3, и итоговая батарея выше — с ним. Каталог `internal/store` я не трогала ни строкой. Оркестратору отправлено строкой.
Пропущенные тесты, как требует Makefile, называю: `TestMinerFullBookParity` (нет стендовых данных), `TestHelperEventsRun`, `TestHelperKillLoop` (точки входа подпроцессов, вне обычного прогона).
Дерево ПОСЛЕ трёх кругов: **20 отслеживаемых файлов правлено (+761/204) и 5 заведено (3106 строк)**; новых тестов **36** (30 в `internal/pipeline`, 6 в `cmd/tmctl`). Числа взяты командами: `git diff --stat -- backend/` для правленых (новые в него не входят — они untracked), `wc -l` для заведённых, `grep -c '^func Test'` для тестов. **Не коммичу — на лендинг.** ⚠ Числа выше по тексту, где они расходятся с этими, относятся к ПЕРВОМУ кругу и оставлены как след, а не как состояние.
**Мутационных посадок 26** (11 на первом круге, 15 на втором и третьем), каждая — на свой пункт, вердикт по ТОПИЧНОСТИ упавшего теста, а не по цвету батареи: снятие сортировки фолда · потолок на единицу больше · снятие допуска в черновой волне · снятие допуска в редакторской · «свободные юниты тратят грант» · возврат к прежней терминальности (воспроизводит перерасход) · `!ok``ok &&` (воспроизводит перерасход) · допуск только лидера (воспроизводит усечённую главу) · фильтр согласия в тождество (воспроизводит отказ законной работе) · перестановка лога замечаний (воспроизводит потерю диагностики) · снятие отказа несогласованной форме конвейера. Все одиннадцать падают, и падает ИМЕННО адресный тест.
#### Таблица комплектности против §3
| Пункт промта | Что сделано | Каким ИСПОЛНЕНИЕМ подтверждено |
|---|---|---|
| **§3.1** потолок ОБЪЁМА рядом с денежным | Флаг `--max-units N` у `translate`; единица — **выходной юнит** (`ManifestUnit`=`editUnit`, `units_total` манифеста); допуск считается ДО волн (`planVolume`, `backend/internal/pipeline/volume.go`), волны не начинают элемент вне скоупа | `TestTheVolumeCeilingStopsAtWhatWasBought` — 5 глав, потолок 2: отгружено ровно 2, `res.Volume` заполнен, у глав 35 **нет ни одной строки `chunk_status`**, вызовов провайдера ровно 4 (2 юнита × 2 стадии) |
| §3.1 — «проверять ПЕРЕД началом единицы» (грабля LiteLLM) | Свойство конструктивное: элемент вне скоупа не начинается вовсе | Тот же тест, клауза (3): отсутствие строк у N+1 и точный счёт вызовов; мутация «потолок на единицу больше» (`Granted <= MaxUnits`) — падают 3 теста |
| §3.1 — «остановка НАЗЫВАЕТ свой потолок» (грабля OpenHands) | `BookResult.Volume` (`VolumeStop{MaxUnits, Delivered, Reworked, Free, LeftFresh, LeftRework}` — ⚠ поля ПЕРЕСТРОЕНЫ на втором круге, см. находку 2), строка лога, строка вывода CLI | `TestAVolumeStopIsNotAMoneyStop` — денежный стоп остаётся типизированным `*CeilingHalt` (exit 4), объёмный не ошибка вовсе и несёт `res.Volume` |
| §3.1 — счёт РЕПИНОВ | Не считаются: считаются только юниты, по которым прогон сделает ≥1 вызов | `TestAResumeDoesNotSpendItsVolumeOnFreeUnits` — вторая покупка 2 юнитов на книге, где 2 уже сделаны: `Free=2`, `Delivered=2`, `LeftFresh=1`, свежих вызовов ровно 4. Мутация «свободные тратят грант» — тест падает |
| §3.1 — счёт РЕТРАЕВ | Не считаются: ретрай/эскалация внутри юнита | `TestRetriesDoNotBurnTheVolumeCeiling` — провайдер отдаёт `finish_reason: length` на первом черновике: юнитов всё равно 2, вызовов >4 |
| §3.1 — форма ответа через шов | Решение оркестратора исполнено: код выхода **0**, словарь `main.go:115-140` не тронут, словарь `runevents` не тронут | `res.ExitCode()==0` в тесте; сверено чтением `platform/internal/runs/reconcile.go:703-706` — exit 0 = статус ПРОГОНА `ready`, книга дочитанной не объявляется |
| §3.1 — ортогональность | `--max-units` не касается леджера, резерваций и кумулятивной семантики `--ceiling-usd` | `TestAVolumeStopIsNotAMoneyStop`; `MaxUnits` не входит ни в `BriefHash`, ни в снапшот-пейлоад |
| **§3.2** смета БЕЗ покупки | Читающие поверхности сворачивают банк **в памяти** из тех же входов, что `seedGlossary` (`projectFoldedMemory`), вместо СОХРАНЁННОГО глоссария | `TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought` — прогон состоялся → `bank-apply`**старая проекция 0, новая ≠0** на ОДНОМ состоянии |
| §3.2 — $0 абсолютный | Ни одного провайдерского вызова | Тот же тест: `rec.count()` не изменился; леджер (`committed`/`reserved`) не двинулся |
| §3.2 — read-only сверх first-touch | Ни одной записи | Тот же тест: **sha256 файла `.db` до и после совпадает**; `status` идёт через `NewReadOnlyRunner``OpenReadOnly` с `PRAGMA query_only=1` |
| §3.2 — ключей не требует | Путь не строит ни одного LLM-клиента (`models.CheckKeys` только `if forWrite`) | Пин `cmd/tmctl/keysfile_test.go` не тронут и зелёный |
| §3.2 — **тождество** сметы и прогона | `storeOrder` воспроизводит `ORDER BY` стора; фолд один на оба пути | `TestTheInMemoryFoldIsIdenticalToTheStoreRoundTrip`оба пути на одном состоянии: строки, voices, pairs, `Version()`, `BaseVersion()` и **карта отрендеренных content-hash** совпадают. Мутация «убрать сортировку» — падает с топичным «row 0 differs» |
| §3.2 — мина `omitempty` | `rebill_units`/`rebill_usd` больше не `omitempty`; добавлено `rebill_basis` (`pending`/`stored`/`none`/`failed`) | `TestTheWireTellsFreeFromUnknown` — «нечего пере-оплачивать» и «посчитано, и это ноль» на проводе различимы |
| §3.2 — книга остаётся инспектируемой | Отказ фолда не валит чтение: фолбэк на сохранённый глоссарий + честный `basis` + WARN | `TestTheEstimateSurvivesABookWhoseFoldRefuses` — сид-коллизия: `status` отвечает, `basis=stored` |
| §3.1 — потолок не отказывает законной работе И не даёт обойти порог дроблением | ⚠ **Строка ПЕРЕПИСАНА после отзыва первой редакции — см. второй круг, находка 1.** Порог судится по ВСЕЙ книге (дробить бесполезно); именованный кап `--accept-rebill=X` судится по тому, что заплатит ЭТОТ прогон (законная работа не отклоняется); прогон, который не пере-оплачивает ничего, гейта не встречает вовсе | `TestABoundedRunCannotOutrunTheConsentThreshold` (обе стороны на одном состоянии) + `TestARunThatRePaysNothingIsNotAskedForConsent` (третья ветка). Мутации: порог по прогону · кап по книге · снятие ранней ветки — все три падают адресно |
| §3.1 — потолок не даёт перерасхода на неполном юните | Терминальность = ПОКРЫТИЕ всех ожидаемых позиций (`unitFullyRecorded`), а не вердикт `resolveChunkState` | `TestAFlaggedMemberWithNoEditRowIsNotFree` — флагнутый член + ноль edit-строк; без фикса юнит признаётся свободным и оплачивается сверх потолка |
| §3.1 — неопределённость всегда в сторону «платно» | `!ok \|\| h != ContentHash` ⇒ невоспроизводимый хеш = платно | `TestAnUnreproducibleHashIsNeverFree` (6 состояний, включая `skipped` и снятую стадию) |
| §3.1 — допускается ВЕСЬ юнит, а не его лидер | Допуск помечает каждого члена | `TestTheCeilingAdmitsEveryMemberOfAUnit` — мелкая нарезка ⇒ 3 чанка в главе; мутация «только лидер» даёт усечённую главу |
| §3.1 — форма конвейера, где «юнит» двусмыслен | `planVolume` ОТКАЗЫВАЕТ (переводчик последней стадией при живых редакторских) | `TestTheCeilingRefusesAPipelineWhoseUnitsAreTwoThings`; без флага — поведение не меняется |
| §3.1 — черновой-только конвейер | Та же единица, другая форма | `TestTheVolumeCeilingHoldsOnADraftOnlyPipeline` — 1 вызов на юнит, 2 гранта = 2 вызова |
| §3.1 — стоп назван в выводе CLI | Строка с тремя действенными числами + «это ЗАВЕРШЕНИЕ, не пауза» | `TestRenderTranslateNamesTheVolumeCeiling`; необрезанный прогон о потолке молчит |
| §3.2 — «неизвестно» никогда не несёт числа | Решение вынесено в чистую `rebillOutcome`/`projectable` | `TestAFailedBasisNeverCarriesAFigure` — таблица на 5 состояний |
| §3.2 — падающий фолд не уносит свою диагностику | Замечания в лог ДО обработки ошибки | `TestAFailedFoldStillReportsWhatItSkipped` |
| §3.1 — отчёт различает ДОСТАВКУ и ПЕРЕ-ДЕЛКУ (добавлено 2-м кругом) | `VolumeStop` раздвоен на `Delivered`/`Reworked` и `LeftFresh`/`LeftRework`; грант идёт сперва в новое; приглашение купить — только про никогда-не-доставленные | `TestAFullyDeliveredBookOffersNothingLeftToBuy` (0 доставлено / 1 переделан / 0 к покупке) · `TestAPurchaseBuysNewBookBeforeReMakingOldBook` (грант уходит в новое, а не в переделку) · `TestRenderTranslateNeverSellsWhatIsAlreadyOwned`. Мутация «схлопнуть два прохода в один» падает |
| §3.1 — движок говорит ЮНИТАМИ, не главами (добавлено 2-м кругом) | Все строки отчёта и CLI переведены на «units»; главы — слово платформы, конвертирует манифест | `TestRenderTranslateNamesTheVolumeCeiling` + `TestAFullyDeliveredBookOffersNothingLeftToBuy` пинят формулировку |
| §3.2 — смета дана и в той единице, которой сайзится покупка (3-й круг) | Аддитивное `rebill_output_units` рядом с `rebill_units` (chunk×stage); имя `rebill_units` не тронуто | `TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn`; фикстура специально недегенеративна; мутация «выдать Rows за юниты» падает |
| §3.2 — один документ = один банк (2-й круг) | `unsigned_bank_terms` считается по ТОМУ ЖЕ банку, что и денежные поля | Материализация помнит свои строки (`r.bankRows`); проверено батареей |
| §5 ось 3 — детерминизм | `MaxUnits` не входит ни в снапшот, ни в `BriefHash`; байты не двигаются | `TestTheVolumeCeilingMovesNoRequestByte` — снапшоты обеих волн не сдвинулись, каждое тело обрезанного прогона есть среди тел безпотолочного; мутация «подмешать MaxUnits в снапшот» падает |
| §3.3 — чего в паке нет | Константу $0.03, цену от объёма исходника, строки 228/230/141/131, `platform/` — не трогал | — |
#### Адверсариальный проход по СВОЕЙ готовой работе — что он нашёл
Веер: шесть линз (деньги · тождество фолда · шов · регрессии пака · параллелизм/детерминизм · качество моих же тестов), тяжёлая модель — РОВНО ОДИН агент (денежная линза), остальным попроще; затем по одному независимому опровергателю на каждую находку. Сырых находок 10, после опровержения осталось 7 настоящих. **Все семь внёс сам этот пак — приёмке они не видны, поэтому называю первыми.**
**1. ⛔ ПЕРЕРАСХОД: юнит с флагнутым членом и без edit-строки считался БЕСПЛАТНЫМ.** `unitsServedFree` спрашивал терминальность у `resolveChunkState`, а тот отдаёт `ChunkFlagged`, как только флагнута ЛЮБАЯ строка, — ДО проверки, что все ожидаемые позиции на месте. Юнит, у которого черновик одного члена флагнут, а edit-строки ещё нет (состояние после подписного стопа банка, Ctrl-C или любого обрыва в редакторской волне), признавался терминальным и свободным, проходил БЕЗ списания гранта и потом оплачивался редактором. Прогон платил за юнитов больше, чем куплено, и, поскольку остаток оставался нулём, **даже не сообщал об объёмном стопе.**
**Лечение:** предикат заменён на `unitFullyRecorded` — покрытие ВСЕХ ожидаемых позиций строками; полнота и есть терминальность, и именно она предсказывает «вызова не будет». **Доказано:** `TestAFlaggedMemberWithNoEditRowIsNotFree` (обрыв редактора на прогоне 1 → флагнутый член + ноль edit-строк; прогон 2 с потолком 1). Возврат к прежнему предикату мутацией — тест падает с «the other unit was judged free and paid for anyway».
**2. ⛔ ОТКАЗ ЗАКОННОЙ РАБОТЕ: гейт согласия судил ВСЮ книгу при обрезанном прогоне.** Нашёл сам, до линз. `checkRebillConsent` стоял ПЕРЕД `planVolume` и проецировал пере-оплату по всем строкам, тогда как прогон с `--max-units 2` пере-оплатит максимум два юнита. Р6 требует согласия на КОНКРЕТНУЮ трату — а тут отказ (или запрос согласия) шёл на деньги, которые никто не потратит. Это ровно то, о чём предупреждает сам `rebill.go`: «число, которое одобрили и не списали, — это то, из-за чего такие числа перестают читать».
**Лечение:** скоуп считается ДО гейта; СУММА сужена до допущенных юнитов, ПОРОГ оставлен книжным (5% книги — свойство книги, не покупки). **Инертно без флага** (`scope==nil` ⇒ фильтр тождественный ⇒ число байт-в-байт прежнее) — это и делает правку ратифицированного гейта безопасной. **Доказано на первом круге** мутацией «фильтр в тождество». ⚠ **Это решение ОТОЗВАНО на втором круге** (находка 1 ниже) — читать вместе с ним, а не отдельно.
**3. Отказ `redrive` учил ЛОЖНОМУ факту об движке.** Текст говорил «`redrive` не гоняет книжную волну, которую это могло бы ограничить» — неправда: `redrive` зовёт `TranslateBook` (`status.go:984`). Мой же комментарий четырьмя строками выше говорил обратное и верное. **Лечение:** у `redrive` теперь свой текст, называющий НАСТОЯЩУЮ причину (деструктивный сброс ограничен своим селектором, и потолок в юнитах ограничил бы вторую половину команды, читаясь как ограничение целого). Тест дотянут: он проверяет не только имя флага, но и что причина не врёт про адресата.
**4. Рефактор `seedGlossary` терял диагностику падающего прогона.** Замечания (пропуск ruby-алиаса по омонимии, дроп строк авто-банка) собирались в `gatherBankInputs`, но логировались ПОСЛЕ проверки ошибки — то есть на прогоне, который умер на более поздней проверке, они пропадали. До рефактора они писались по месту и доезжали. **Лечение:** замечания уходят в лог ДО обработки ошибки; `gatherBankInputs` для этого возвращает частичные входы вместе с ошибкой. **Доказано:** `TestAFailedFoldStillReportsWhatItSkipped`; перестановка двух блоков мутацией — тест падает.
**5. `rebill_basis: failed` мог ехать рядом с большим ПОСЧИТАННЫМ числом.** Если банк не материализовался вовсе, `projectRebill` всё равно запускался — против ПУСТОГО банка, поэтому все строки выглядели протухшими и на провод уезжала пере-оплата всей книги, а CLI печатал рядом «цифры ниже нули, потому что их не удалось посчитать». Базис, противоречащий собственным числам, хуже отсутствия базиса. **Лечение:** решение вынесено в чистую `rebillOutcome` (+ `projectable`) — проекция не запускается без банка, «неизвестно» никогда не несёт числа. Табличный тест на все пять состояний; **инвариант недостижим из фикстуры** (нужен одновременный отказ и фолда, и стора), поэтому он и вынесен в функцию — иначе его нечем было бы держать.
**6. Потолок отказывается от формы конвейера, в которой «юнит» — два разных объекта.** Если ПОСЛЕДНЯЯ стадия — переводчик, а редакторские стадии есть (конфиг такой порядок не запрещает), `outputUnits` отдаёт синглтон на ЧАНК, а редакторская волна всё ещё группирует их в юниты: потолок, выданный в синглтонах, допустил бы чанк одного члена, и редактор пошёл бы по юниту с недоделанным черновиком за полную цену. **Лечение:** `planVolume` на такой форме ОТКАЗЫВАЕТ громко (без флага — ничего не меняется). Тест + мутация.
**7. Мои собственные тесты доказывали меньше, чем обещали — три дыры, все закрыты.**
(а) **Мульти-членный юнит не проверялся ни одной фикстурой** (все главы были однопредложенные ⇒ у каждого юнита ровно один член), поэтому цикл по `u.Members` в допуске был непокрыт: мутация «помечать только лидера» проходила ВСЮ батарею. Закрыто `TestTheCeilingAdmitsEveryMemberOfAUnit` (мелкая нарезка черновика под крупным редакторским потолком ⇒ 3 чанка в главе); мутация теперь падает с «buyer gets a truncated chapter they paid for in full».
(б) **Ветка «хеш не воспроизведён ВООБЩЕ» была непокрыта**а это направляющий инвариант всего потолка («любая неопределённость — в сторону „платно“»). Мутация `!ok || …``ok && …` проходила батарею. Закрыто `TestAnUnreproducibleHashIsNeverFree`.
(в) **Тест тождества фолда сравнивал ТОЛЬКО черновую волну.** Путь A не гонял `TranslateBook`, поэтому строк `chunk_status` не было, и `renderedContentHashes` не давал ни одной edit-позиции — а edit-волна ровно та, куда попадают `mined`-строки фикстуры (базовый банк их исключает). То есть половина, ради которой фикстура и заводилась, не проверялась. Закрыто: перед сравнением идёт настоящий прогон, и **покрытие обеих волн теперь АССЕРТИТСЯ**, а не предполагается (`len(hashes)!=0` от этого не защищал: две черновые карты тоже равны).
⚠ **ОТДЕЛЬНОЙ СТРОКОЙ, потому что это правка РАТИФИЦИРОВАННОГО контура и она обязана быть видна в акте, а не выводиться из диффа: я тронула гейт согласия D20.2-Q2.**
**ЧИТАТЬ ВМЕСТЕ С НАХОДКОЙ 1 ВТОРОГО КРУГА — здесь описана ПЕРВАЯ, ОТОЗВАННАЯ редакция.** Она сужала СУММУ до допущенных юнитов при книжном ПОРОГЕ и защищалась доводом «инертно без флага». Довод верный и неважный: с флагом размер прогона выбирает каллер, и дробление обходило порог. **Действующая редакция:** порог — по всей книге (дробить бесполезно), именованный кап — по тому, что заплатит этот прогон (законная работа не отклоняется), прогон без пере-оплаты гейта не встречает. Инертность без флага сохранена и в ней: при `scope == nil` обе проекции — один объект, и число байт-в-байт прежнее.
**Опровергнуто и НЕ чинилось (3 из 10):** три находки опровергатель снял как уже вылеченные тем же проходом (я чинил по мере поступления, и опровергатели читали уже исправленный код) — они и есть пункты 3, 4 и 7(а/б) выше.
#### Второй круг приёмки — что нашли и что я с этим сделала
Оркестратор прогнал по паку ещё два аппарата: слепой верификатор (читал ТОЛЬКО заказ и дифф, отчёта не видел) и искателя вне карты отчёта. Плюс я прогнала свой второй код-ревью — шесть линз, тяжёлая модель один агент, по опровергателю на находку. Итог круга: **двенадцать настоящих находок, все внесены паком, все вылечены** — плюс одна чужая, проверенная и переданная строкой.
**1. ⛔ ОТЗЫВ МОЕГО ЖЕ РЕШЕНИЯ: сужение суммы гейта D20.2-Q2 открывало обход порога дроблением.** Первый круг я закрыла тем, что сумму пере-оплаты считаю по допущенным юнитам, а порог оставляю книжным, и защитила это доводом «правка инертна без флага». Довод верный и НЕважный: с флагом каллер САМ выбирает, насколько мал каждый прогон, поэтому `--resnapshot --max-units N` в цикле пере-оплачивает книгу целиком, ни разу не перейдя порог и ни разу не спросив согласия. Измерено искателем на реальных числах: 30 глав за $0.109200, порог $0.005460, безпотолочный прогон ОТКАЗЫВАЕТ, а `--max-units 1` проходит четыре раза подряд по $0.003640. Порог обхода арифметический: покупка ≤5% книги — гейт молчит; на 500-главной вебновелле «10 глав» это 2%.
**Лечение — разделены два РАЗНЫХ вопроса, а не один компромисс.** «Надо ли вообще спрашивать человека» — политика КНИГИ, судится по всей её пере-оплате, которая от размера покупки не сжимается. «`--accept-rebill=X`» — инструкция каллера о ТРАТЕ, судится по тому, что этот прогон реально заплатит (платформа фондирует этот кап из холда прогона, то есть он размерен покупкой, а не книгой). Отказ печатает ОБА числа: собрать их в одно — и есть способ соврать в любую сторону.
**Доказано:** `TestABoundedRunCannotOutrunTheConsentThreshold`обе стороны на одном состоянии: потолок 1 без согласия ОТКАЗЫВАЕТ (обход закрыт), а именованный кап $0.002, покрывающий собственный счёт прогона ~$0.00182, ЧЕСТЁН (законная работа не отклонена).
**2. ⛔ `VolumeStop` не различал ДОСТАВКУ и ПЕРЕ-ДЕЛКУ — и это была нехватка модели, а не сбой счётчика.** Один счётчик на оба вида оплаченного юнита давал ложь, на которую оператор действует: книга переведена на 100%, банк подвинут, покупка №1 печатает «29 остались», №2 — «28», ни одной новой главы не создано, exit 0 и `ready` подтверждают успех. А CLI подавал остаток как «что осталось КУПИТЬ».
**Лечение — переделан тип, а не подлатан счёт.** Юнит классифицируется тремя состояниями (`unitFresh` / `unitFree` / `unitRework`), и обе половины отчёта раздвоены: `Delivered`/`Reworked` и `LeftFresh`/`LeftRework`. Приглашение купить произносится ТОЛЬКО про никогда-не-доставленные юниты; покупка, целиком ушедшая в переделку, обязана это сказать. Раздвоение бесплатно: оно падает из того же теста полноты позиций, которым считается «бесплатно».
**Доказано:** `TestAFullyDeliveredBookOffersNothingLeftToBuy` (0 доставлено, 1 переделан, 0 никогда-не-доставленных, остаток назван пере-проходом) + `TestRenderTranslateNeverSellsWhatIsAlreadyOwned` (строка CLI не предлагает купить то, чем уже владеют).
**3. ⛔ `status`/`export` начали читать авто-банк, который писался НЕ атомарно.** Дыра, которую пак открыл в чужом файле: до него авто-банк читал только тот прогон, который его и писал, поэтому `os.WriteFile` с усечением был безопасен. Как только читающие поверхности стали сворачивать банк сами, файл попал в класс, ради которого существует `artifact.go`, — и окно усечения даёт либо ошибку разбора (покупателю устаревшие цифры), либо валидный ПРЕФИКС списка термов: фолд банка, которого не существовало, и `rebill_units`, который ни один прогон не выставит.
**Лечение:** `writeFileAtomic`, как у трёх соседей; авто-банк вписан в перечень сайдкаров в шапке `artifact.go`. **Доказано:** `TestTheAutoBankIsWrittenAtomically` — инструмент inode (переименование меняет его ВСЕГДА, запись на месте — никогда; сильнее сверки байтов).
**4. Пин согласия `status``translate` покрывал только беспотолочный случай.** Механическая правка сигнатуры протащила туда `nil`, и расхождение под потолком пин бы не поймал. Дописан второй ассерт: под объёмным потолком отказ гейта обязан по-прежнему цитировать КНИЖНОЕ число — то самое, которое печатает `status`, — потому что докстринг `status.go` обещает это дословно.
**5. Отказ денежного потолка съедал объёмный отчёт.** Когда прогон встаёт по деньгам, результата нет, и оператор не узнаёт, что ему было выдано и сколько успело доехать. Добавлена строка WARN на этом пути: стоп ниже — НЕ объёмный, вот что было выдано и что осталось недоставленным.
**6. Инструмент «ничего не записано» был слеп к WAL — и, дотянутый, сразу поймал факт.** Дайджест мерил только основной файл, а SQLite в WAL кладёт коммит в `-wal`. Дотянула — и увидела изменение. **Разобрала, а не подогнала:** данные не двигались (основной файл байт-в-байт), а появились ПУСТОЙ `-wal` и `-shm`: так устроен любой WAL-читатель. Поэтому ассерт теперь точный: основной файл неизменен И журнал не несёт ни одного кадра; `-shm` не проверяется намеренно, требовать его отсутствия значило бы требовать от SQLite невозможного. Гарантию держит `query_only`, дайджест — подтверждение.
**Косметика того же круга:** висячие ссылки на `bankfold.go`/`bankfold_identity_test.go` исправлены на реальные `bankmaterialize*` · `backend/README.md` (позиция №2 онбординга зоны) теперь знает про ВТОРОЙ потолок, его ортогональность денежному, «стоп = завершение» и предупреждение про майнящие книги · в `volume.go` явно записано, что нулевое значение `unitClass` обязано быть `unitFresh` (пустая карта = книга, которую не гоняли).
**7. ⛔ ПОТОЛОК ТРАТИЛ ПОКУПКУ НА ПЕРЕ-ДЕЛКУ ВПЕРЁД ДОСТАВКИ.** Допуск шёл одним проходом в порядке книги, а после правки банка уже доставленные юниты — это как раз РАННИЕ. Воспроизведено ревью: покупка 2 юнитов на пятиглавой книге с двумя доставленными пере-делала главы 12 и не начинала главу 3 — `Delivered=0, Reworked=2, LeftFresh=3`. Деньги списаны, книга не сдвинулась ни на главу, а три никогда не переводившиеся главы остались нетронутыми.
**Лечение:** грант идёт СНАЧАЛА в новое, и только потом в переделку. Безопасно потому, что байты юнита не зависят от того, какие ДРУГИЕ юниты шли: липкая выборка считается по ВСЕЙ книге до обеих волн (`precomputeSticky`), поэтому юнит 3 раньше юнита 1 рендерится ровно так же. **Размен назван, а не спрятан:** покупка, ЦЕЛЬЮ которой был пере-проход, теперь потратит грант на недоставленные главы, если они есть. Это правильное умолчание — непрочитанная глава ценнее пере-сделанной, а пере-оплату и так ограничивает гейт согласия, — но это УМОЛЧАНИЕ, и я выношу его оркестратору как выбор, а не как факт.
**8. ⛔ МОЯ ЖЕ СТРОКА СТОПА СООБЩАЛА ЮНИТЫ КАК ГЛАВЫ.** `«%d NEW chapter(s) delivered»` печаталось поверх счётчика, который инкрементится на `editUnit`, — а глава это больше одного юнита всякий раз, когда нарезка закрывает юнит по редакторскому потолку, и много юнитов в черновом-только конвейере. **То есть подмену «юнит↔глава», ради устранения которой пак и заведён, совершал отчёт самого пака** — и мой же тест пинил эту формулировку как инвариант. Воспроизведено: одна глава, три юнита, потолок 2 → «2 NEW chapter(s) delivered» на книге из ОДНОЙ незавершённой главы.
**Лечение:** движок говорит только «units», везде. Главы — слово ПЛАТФОРМЫ, и конвертирует их она, манифестом. Тесты пере-пинены на верную формулировку.
**9. Один документ `status` описывал ДВА банка.** Денежные поля (`rebill_*`) считались от свёртки файлов решений, а `unsigned_bank_terms` — по-прежнему от СОХРАНЁННОГО глоссария. Оператор, спрашивающий «есть ли что подписать, прежде чем платить дальше», получал ноль, тогда как ближайший `translate` уже собирался инжектить неподписанную строку из авто-банка и оплатить изменённый ею юнит. **Лечение:** материализация запоминает строки, из которых собрана, и счёт неподписанных идёт по ТОМУ ЖЕ банку. Один документ — один банк.
**10. Цифры в отказе гейта были СМЕШАННОГО происхождения.** В книжную фразу вклеивался скоупный `Repinned`, то есть в предложении, где все остальные числа книжные, стояло меньшее число из другой проекции. Две линзы поймали независимо — что смешанные провенансы в одном предложении и производят. **Лечение:** каждое число берётся из той же проекции, что и фраза; скоупная оговорка несёт скоупный ре-пин.
**11. `export` ОТКАЧЕН — и это отказ от собственной правки, а не находка чужого.** Я перевела его `ConfigDrift` на свёртку «за компанию» со `status`. Но это поле читает полигонная экстракция, и её вопрос ДРУГОЙ: «согласован ли ЭТОТ документ с прогоном, который его произвёл». Экспорт, байты которого — ровно то, что отгрузил прогон, измерим и при лежащем рядом неприменённом файле решений; свёртка помечала бы такой экспорт дрейфующим и тихо дисквалифицировала бы здоровые замеры в чужой зоне. **Пере-определять чужое потребляемое поле как побочный эффект своего заказа — не моё право.** Расхождение теперь ЯВНОЕ и запинено `TestExportAndStatusAnswerDifferentDriftQuestions`, чтобы следующий не «починил» его обратно.
**12. Дыры в моих же тестах, найденные второй линзой качества:**
(а) **ни один тест не звал CLI-`translate` с `--max-units`** — то есть единственная строка, которая проводит флаг в движок (`r.MaxUnits = maxUnits`), была не проверена вовсе, и её удаление оставило бы всю батарею зелёной, пока `tmctl translate --max-units 2` переводит книгу целиком. Закрыто `TestTranslateWiresMaxUnits` по образцу соседнего `TestTranslateWiresAcceptRebill`; мутация «убрать проводку» падает с точным сообщением.
(б) **ветка «прогон не пере-оплачивает ничего» не была покрыта** — её удаление проходило всю батарею, а она ровно то, что не даёт гейту отказать законной работе. Закрыто `TestARunThatRePaysNothingIsNotAskedForConsent` (состояние строится через `ResetChunkStages`, как делает redrive).
(в) **`export` после правки банка не был покрыт ничем** — теперь покрыт, вместе с решением о расхождении.
**13. И находка НЕ из моей зоны, проверенная как положено.** `internal/store` дважды уронил мою батарею (`TestTheSeamIsNotChargedToTheStoreOperationBudget`, `apply migration 14: context deadline exceeded`). Я этот каталог не трогала ни строкой. Развернула ЧИСТЫЙ HEAD (`git archive`) в отдельный каталог и погоняла тот же тест в обоих деревьях вперемежку по пять раз: **моё падает 4/5, HEAD падает 4/5** — от моих правок не зависит. Причина в самом тесте: он ужимает `opTimeout` до 150 мс (`migrate_test.go:408-429`), и под параллельной нагрузкой одиночный ALTER в него не укладывается. Оркестратору отправлено строкой; сама не чиню — чужая зона.
**И правило, которое из этого следует, шире случая: ЗЕЛЁНАЯ БАТАРЕЯ — ЭТО ПОЛНЫЙ СПИСОК ПАКЕТОВ ПЛЮС ОТСУТСТВИЕ `FAIL`, а не отсутствие `FAIL`.** Мой первый «зелёный» прогон был на 20 пакетов из 21, с `FAIL` в хвосте, который обёртка подала как `exit code 0`. Сверяю против `go list ./...` теперь всегда.
**И одна поправка к моему же первому отчёту.** В записке-плане я написала, что предикат «бесплатно» — «тот же, которым считает `projectRebill`, чтобы они не разошлись двумя определениями». Это НЕВЕРНО и я поправляю: `projectRebill` намеренно игнорирует ось СОДЕРЖИМОГО (его собственный докблок это и говорит — правка исходника двигает `content_hash`, но не снапшот, и там она невидима), а предикат потолка сверяет отрендеренный хеш на КАЖДОЙ строке. То есть они расходятся, и расходятся в безопасную сторону: потолок считает платным то, что смета считает бесплатным. Предикат написан против ИСПОЛНИТЕЛЯ (`runStage`), а не заимствован у проекции, и в `volume.go` это теперь записано явно.
#### Третий круг — ратификация ОПУСАМИ трёх развилок, где я сама не была уверена
Владелец снял потолок на веер и назвал главное: **ратификаторы — опусы, особенно на важном.** Поэтому три развилки, которые я не имела права закрыть своим словом, ушли трём опус-судьям с мандатом «настаивай на своём, а не соглашайся». Плюс второй код-ревью в шесть линз с опровергателями (15 сырых находок, 4 пережили). Модель каждому агенту задавалась ЯВНО.
**Развилка 1 — «капнутое согласие: дыра или согласие?» → ВЕРДИКТ: ЗДОРОВО, менять нечего.** Ревьюер показал цикл: `--resnapshot --max-units 1 --accept-rebill=0.002` четыре раза пере-оплачивает книгу целиком, ни разу не перейдя порог. Ратификатор воспроизвёл цикл СВОИМ прогоном и разобрал по существу: (а) каждый прогон явно называет сумму и списывает ровно её (замерено: заявлено $0.001820 — списано $0.001820); (б) каждый прогон ПИШЕТ рядом книжную цифру и порог, то есть каллеру прямо сказано, что книга выкупается по кусочку; (в) суммарно цикл не достигает ничего, чего одиночный голый `--accept-rebill` не достигает за один вызов; (г) кумулятивный денежный потолок дроблением не обходится. **Это Р6 в чистом виде — согласие на КОНКРЕТНУЮ трату, данное явно и многократно.**
**Его же рекомендация принята:** свойство, которое делает это согласием, а не дырой, — раскрытие — раньше не было запинено ничем. Теперь есть `TestAConsentedSliceIsAlwaysToldWhatTheBookCarries`; мутация «убрать книжные цифры из строки согласия» падает.
**Развилка 2 — «денominация двух половин пака» → ВЕРДИКТ: ДЕФЕКТ, ЧИНИТЬ.** И он измерил, а не вывел: на трёхглавой фикстуре один документ `status --json` нёс `rebill_units: 15` двумя строками ниже `total_units: 6`. Все остальные «units» в документе — ВЫХОДНЫЕ юниты (`total_units`, прогресс, `chapters[].units_total`), а `rebill_units` единственный считает `chunk×stage`, и коллизия сидит в самом слове. **Поделить нельзя:** отношение `len(Members)·nDraftStages + nEditStages` меняется от юнита к юниту ВНУТРИ книги, а его множители на провод не ходят вовсе. Траектория: платформа берёт смету, которую пак сам просит вернуть в аллоулист, и передаёт это число в `--max-units` — покупает втрое больше книги, чем собиралась. **Дефект пака, воспроизведённый слоем ниже, ровно тот, ради устранения которого пак заведён.**
**Лечение — его форма, аддитивная:** `rebill_units` НЕ переименован (устоявшееся имя, смысл не менялся), рядом заведено `rebill_output_units` — та же пере-оплата в той единице, которой сайзится покупка. Человеческий рендер печатает обе с подписями. `RebillProjection.OutputUnits` считается по лидерам затронутых юнитов там же, где инкрементится `Rows`. **Доказано:** `TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn` — фикстура специально сделана недегенеративной (правка КОНФИГА, а не банка: банковая правка двигает только редакторскую волну и обе денominации совпали бы, и тест ничего бы не доказал); мутация «выдать `Rows` за выходные юниты» падает.
**Развилка 3 — «безопасно ли ставить доставку впереди переделки» → ВЕРДИКТ: ЗДОРОВО, и обосновано замером, а не рассуждением.** Ратификатор проверил три вещи исполнением. (а) **Порядок безопасен:** `precomputeSticky` — чистая функция от (чанки, банк, бюджет), считается ДО допуска и потребляется позиционно, поэтому инъекция чанка i не зависит от того, шёл ли чанк i1; редакторская волна берёт только СВОИ члены и делает свежий Select с nil-sticky. Он прогнал книгу, где глава 4 переведена и отредактирована в прогоне, где глава 1 не запускалась вовсе, — **тела запросов дословно совпали с телами прямого сплошного прогона.** Проверил и остальные кандидаты на кросс-юнитную связь: стоп майнинга строит кандидатов по ВСЕМ чанкам (от скоупа не зависит), свёртка банкнот — множественная (порядок не важен), счётчики событий засеиваются книжно и инкрементятся под once-key. (б) **A/B на одной книге, четыре покупки по 2 юнита:** доставка-вперёд отдаёт 6/6, порядок книги — 4/6, причём при порядке книги две покупки подряд печатали «Still in the book: 4 unit(s) NEVER delivered» не сдвинувшись. (в) **Дверь пере-прохода не сломана:** на полностью доставленной книге доставка-вперёд ЕСТЬ порядок книги (свежих юнитов нет), то есть чистый пере-проход работает как прежде.
**Его требование выполнено — и оно было существенным:** это не живой дефект, а ТРОПА ГНИЕНИЯ. Схлопывание двух проходов обратно в один оставляло батарею ЗЕЛЁНОЙ, то есть следующая сессия, «прибирающая» цикл или добавляющая четвёртый класс юнита, тихо вернула бы дефект. Закрыто `TestAPurchaseBuysNewBookBeforeReMakingOldBook`; мутация «схлопнуть в один проход по порядку книги» падает — и падает ровно на вторичном следствии, которое ратификатор и предсказал: при порядке книги та же покупка не просто перераспределяется, а ПОЛУЧАЕТ ОТКАЗ гейта согласия.
**Дополнительно из второго ревью (пережили опровержение и вылечены):**
**тройное повторение самого дорогого расчёта.** Обрезанный прогон пере-рендеривал всю книгу до трёх раз: в `planVolume` и в каждой из двух проекций гейта, при том что между ними ничего не менялось. Заведён мемо, **срок жизни которого — жизнь БАНКА**: `materializeBanks` его сбрасывает, потому что переживший свой банк мемо отдал бы хеши банка, которого у прогона больше нет, — ровно тот класс тихой неверности, ради устранения которого пак и существует. Мемо — скорость, а его инвалидация — корректность, поэтому запинена именно она (`TestTheWireHashMemoDiesWithItsBank`).
**предупреждение про майнящие книги не было покрыто ничем**а незакрытое тестом предупреждение молча перестаёт эмититься. Закрыто, вместе с обратной стороной: на книге БЕЗ потолка оно не должно возникать.
#### Четвёртый круг — денежный охотник приёмки: 6 находок, 3 вылечены, 3 переданы
**1. ⛔ БЛОКИРУЮЩАЯ, и она пробивала саму цель пака — ВЫЛЕЧЕНА.** `planVolume` классифицирует ДО черновой волны, но между волнами стоит стоп майнинга, который **пере-сеивает банк прямо посреди прогона** (`mining.go`, ветка авто-продолжения), после чего `waverun` пере-считывает edit-снапшот. А free-юниты допускаются ВНЕ гранта — потому что бесплатная работа ничего не стоит. Значит каждый из них судился по снапшоту, который прогон сам же и заменил, и любой, чьи инжектируемые байты новый банк изменил, получал свежий ПЛАТНЫЙ вызов редактора, не разрешённый никаким грантом, — а строка отчёта называла его «rode along at $0». Охотник замерил: 4 вызова при гранте 1, $0.007280 объявлены бесплатными.
**Лечение — не гард, а ПЕРЕ-ПЛАН:** вопрос «бесплатен ли юнит» задаётся заново, против снапшота, который стал реальным, в последний момент перед редакторской волной — то есть «проверять перед единицей» по-прежнему держится. Ставший платным берёт слот, если он есть; если нет — его редактура НЕ идёт, и он честно отчитывается как доставленный-но-не-пере-сделанный.
**И сделано СТРУКТУРНО, а не вызовом:** волна ОТКАЗЫВАЕТСЯ работать со скоупом, спланированным против другого снапшота. Без этого удаление вызова пере-плана оставляло всю батарею зелёной, а потолок молча переставал держать — я это проверила мутацией. Теперь удаление вызова роняет прогон с адресным сообщением.
**Доказано:** `TestFreeUnitsAreReJudgedWhenTheBankMovesMidRun` (снапшот не двигался → ничего не меняется; двигался → ни одного «free», ровно один слот занят, остальные удержаны и отказаны `allowsUnit`) + `TestTheEditWaveRefusesAStalePlan`. Две мутации.
**Своей фикстурой конечное следствие я не воспроизвела — ПЯТЬ попыток, — и разбор этого провала оказался ценнее самих попыток.** Две причины, обе названы приёмкой и обе проверены мной на своём дереве.
**Причина 1: я искала не на той стороне дельты.** У mined-дельты две стороны. **WHICH** (список термов) майнится из исходника ВСЕЙ книги — он полон после первой покупки и больше не двигается; отсюда мой неверный вывод «однородный источник ничего не даст». А байты банка двигает **WHAT**`dst` каждого терма, и она **draft-side**: складывается из банкнот, которые модель уже выдала (`mining.go:109`), **то есть растёт с каждой покупкой ДАЖЕ при полностью однородном источнике**. Я меняла ТЕКСТ глав, а надо было менять то, что ПРЕДЛАГАЮТ ЧЕРНОВИКИ.
**Причина 2, из-за которой попытка (5) не могла сработать в принципе, — спойлер-гейт `since_ch`** (`membank/memory.go:640-647`, жёсткое `chapter < since_ch`). Вводя терм поздними главами, я выталкивала его `since_ch` за пределы уже доставленных — и он не попал бы в их инъекцию НИКОГДА, какой бы `dst` потом ни получил. ⚠ **Условия тянут в РАЗНЫЕ стороны:** естественный способ заставить дельту расти (поздние главы вводят новый терм) ровно этим и убивает достижимость. Дефекту нужна ОБРАТНАЯ асимметрия: терм присутствует в РАННЕМ исходнике, а его рендеринг приходит поздно — то есть асимметрия ПОВЕДЕНИЯ МОДЕЛИ, а не текста.
**Пере-проверила зондом приёмки у себя:** `方源 dst="" since_ch=3``dst=Фан Юань since_ch=3`, банк двинулся, а `content_hash` главы 1 `MOVED=false`, вызовов ровно два. То есть мой прогон был верен, а гипотеза о причине — нет.
**Диагностика на будущее, две строки:** дампить авто-банк между покупками и смотреть у терма ДВА поля — что `dst` перешёл из пустого в непустой **и** что `since_ch` ≤ номера уже доставленной главы. Второго я не проверяла.
**Сценарный тест теперь есть** — `TestAMidRunBankMoveNeverBillsBeyondTheGrant` (рецепт приёмки: четыре главы, побайтово одинаковые, различаются только банкнотами). **Я пере-проверила его двумя своими посадками, а не приняла на слово:** снятие вызова пере-плана падает структурным отказом; снятие вызова И гарда воспроизводит дефект целиком — **4 вызова вместо 2, $0.007280 вместо $0.003640, обе уже купленные главы пере-отредактированы, и отчёт зовёт их «2 rode along at $0»**.
**Оба пина стоят рядом и не заменяют друг друга** (различение оркестратора, точнее моего): инвариантный утверждает «пере-план вызывается», сценарный — «покупатель не платит второй раз», а платит покупатель именно за второе.
**Жёсткое равенство `== 2×грант` оставлено намеренно:** гейт терминолога в фикстуре выключен явно, счёт детерминирован, и равенство ловит не только перерасход, но и молчаливую ПОТЕРЮ оплаченной работы.
**Условие достижимости УЗКОЕ, и это записано с обеих сторон:** нужна конъюнкция трёх условий (терм на WHICH-списке · его `dst` приходит поздно · `since_ch` покрывает уже доставленную главу). В фикстуре приёмки два терма из трёх её не выполняют и остаются инертными. Оценку «покупка №50 переоплачивает 490 юнитов» автор находки признал ПОТОЛКОМ ТЯЖЕСТИ, когда сработало, а не ожидаемым случаем; частоту на реальной книге никто не мерил. На диспозицию не влияет — лечение структурное и покрывает класс независимо от узости входа.
**2. ⛔ Флагнутый юнит объявлялся доставленным — ВЫЛЕЧЕНА.** Допуск решается ДО работы (этим и ограничиваются деньги), но решение ЗАПЛАТИТЬ за юнит — не факт существования главы: юнит может вернуться флагнутым и не отгрузить ничего. Отчёт печатал «2 NEW unit(s) delivered» поверх одного читаемого юнита. **Лечение: счётчики выравниваются ПОСЛЕ волн, по фактическим исходам** (`reconcile`), и заведён отдельный счётчик «оплачено, но флагнуто» с прямой формулировкой: деньги потрачены, текста нет, покупкой не чинится — это `redrive`. Тест + мутация.
**6. Подсказка обещала то, чего грант не сделает — ВЫЛЕЧЕНА.** `rebill_output_units` подавался как «та единица, в которой считает `--max-units`» — правда, и в одиночку вводит в заблуждение: грант идёт СПЕРВА в недоставленное, поэтому на книге, где оно есть, `--max-units N` доставит новое и не пере-сделает ничего. Оговорка была в коде, но не там, где читает оператор. Теперь в подсказке. Тест.
**3 и 4 — ПОДТВЕРЖДАЮ, НЕ вылечила, и говорю почему.** Обе об одном: ось «свежий/пере-делка» выведена из ПОЛНОТЫ СТРОК, а не из факта отгрузки. Добавление стадии делает доставленную книгу «никогда не доставлявшейся» (#3); юнит, прерванный между волнами, второй раз считается свежим и тратит слот повторно (#4). **Правильный носитель у движка уже есть — реестр анонсов (`events_outbox.once_key`, `unitOnceKey` = книга+волна+юнит), то есть собственное определение «этот юнит был отгружен читателю», монотонное на всю жизнь книги.** Но читающего метода у стора нет (есть только `EnqueueOnce`, который ПИШЕТ), а `internal/store` я не трогала ни строкой за весь пак и не считаю правильным открывать его в хвосте четвёртого круга. **Это отдельный заказ на одну функцию чтения + перевод оси на неё.**
**ПОПРАВКА к моей же формулировке, внесена оркестратором при приёмке и принята.** Я написала «деньги целы: ошибается слово, а не потолок». Слишком мягко — **слово тут ПРОДАЁТ.** Отчёт, называющий дочитанную книгу «3 unit(s) NEVER delivered», через строку CLI приглашает их купить, и покупатель платит за уже полученное. Деньги целы В ДВИЖКЕ — потолок держит, счёт вызовов верен, — но не в том, что увидит платформа и предъявит покупателю. Диспозиция не меняется (предмет отдельный, `internal/store` в хвосте пака не открываю), но цена ошибки — покупательская, а не редакторская, и в строке заказа стоит именно так.
**5 — цифру принимаю, договорённость в силе, но она теперь важнее.** Терминолог переигрывается каждой покупкой: три покупки по 1 юниту дали три полнокнижных консолидации по `usd=0.005460` каждая, при том что сам юнит дешевле. **Накладные масштабируются КНИГОЙ, а не грантом** — книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. Мы согласились, что место этой траты — в цене; при мелкой нарезке продажи она перестаёт быть накладной и становится основной статьёй, и цена обязана это знать.
#### Сверка по буллетам промта — что закрыто и чем
**§3.2, «родня 124(в)» — заказано сказать ПРЯМО, говорю:** да, моя форма закрывает и `resnapshot --dry-run`. `status` считает ре-билл при ЛЮБОМ дрейфе, не только банковом (`status.go`: проекция идёт при наличии строк, а не под `ConfigDrift`), поэтому после починки свёртки он И ЕСТЬ «оценить пере-проход, ничего не покупая» — ровно та способность, ради которой строка 124(в) поименована будущей дверью. **Отдельного глагола `resnapshot --dry-run` заводить не нужно.** Это удешевляет проект на один глагол и один документ.
**§3.2, «ключей не требует» — закрыто структурно, а не тестом, и вот почему это правильно:** `models.CheckKeys` вызывается ТОЛЬКО под `if forWrite` (`runner.go:260-266`), а `status`/`export` идут через `NewReadOnlyRunner`. Своего глагола пак не завёл, поэтому пин `keysfile_test.go`, перечисляющий читающие глаголы, покрывает мой путь без изменений — и он зелёный (6 тестов). Новый тест здесь ничего бы не доказал сверх пина.
**§5 ось 2 («пара, которой в репо нет») — проверено:** в `volume.go` и `bankmaterialize.go` ветвлений по паре/книге нет. Единственное упоминание языка — `lang.TargetChecksFor(r.Book.TargetLang)`, и это ДАННЫЕ: лукап по встроенным файлам `target-<lang>.txt` (`lang/embedded.go:106-118`), а не свитч. Строка перенесена из `seedGlossary` дословно, новой пар-логики пак не вносит. Пара, которой в репо нет, работает добавлением файла данных.
**§5 ось 3 (детерминизм) — была заявлена без доказательства, теперь доказана:** `TestTheVolumeCeilingMovesNoRequestByte` — снапшоты обеих волн не двигаются от установки потолка, и КАЖДОЕ тело запроса обрезанного прогона встречается среди тел безпотолочного (в том числе когда потолок отдаёт юниты ВНЕ порядка книги). Мутация «подмешать `MaxUnits` в снапшот» падает с точным сообщением. Это несущее: сдвинь потолок снапшот — и передача флага пере-покупала бы всю уже оплаченную книгу.
**§3.3, соседи — называю строкой, не чиню:**
**`redrive` зовёт `TranslateBook` по ВСЕЙ книге** (`status.go:926`): своя денежная неожиданность, была до пака. Флаг `--max-units` я ему сознательно не дал, отказ несёт настоящую причину.
**мёртвая переменная `manualSrcs`** в прежнем `seedGlossary` (писалась, не читалась никем) — исчезла при выносе гathera; ничего с ней не потерялось, проверено грепом по старой версии.
**джоб-гард не отличает банк-онли-движение от смены конфига** (`stagerun.go:47-59`): он и есть корень хвоста про майнящие книги. Ратифицированный Р6-контур, чужой предмет — не трогала.
#### Диспозиция ВСЕХ находок ревью — 25 сырых, ни одна не потеряна
Сведено из журналов двух ревью-воркфлоу (10 + 15 сырых находок) плюс трёх опус-ратификаций. Каждая строка — либо ВЫЛЕЧЕНА (с тестом и мутацией), либо ОТКЛОНЕНА с доводом, либо НАЗВАНА и НЕ вылечена с указанием, почему и чей это предмет. Пустых клеток нет намеренно: находка без диспозиции — это находка, которую следующая сессия найдёт заново.
| # | Находка | Диспозиция |
|---|---|---|
| 1 | Отказ `--max-units` учил ЛОЖНОМУ факту про `redrive` | ✅ свой текст с настоящей причиной + тест, проверяющий, что причина не врёт про адресата |
| 2 | Рефактор `seedGlossary` терял замечания падающего прогона | ✅ лог ДО обработки ошибки; тест + мутация |
| 3 | Мульти-членный юнит не покрыт ни одной фикстурой | ✅ мелкая нарезка (3 чанка в главе); мутация «допускать лидера» падает |
| 4 | Ветка «хеш не воспроизведён ВООБЩЕ» не покрыта | ✅ `TestAnUnreproducibleHashIsNeverFree`, 6 состояний |
| 5 | Тест тождества сравнивал только черновую волну | ✅ прогон ПЕРЕД сравнением + ассерт покрытия ОБЕИХ волн |
| 6 | ⛔ Перерасход: флагнутый член без edit-строки считался бесплатным | ✅ `unitFullyRecorded` вместо вердикта `resolveChunkState`; репро-тест + мутация |
| 7 | ⛔ Вторая покупка на МАЙНЯЩЕЙ книге падает `exit 1` | ⚠ **НЕ вылечено, названо.** Корень — джоб-гард (`stagerun.go:47-59`), ратифицированный Р6-контур, чужой предмет. Мною добавлен громкий WARN + тест на него; проводка гейчена оркестратором |
| 8 | Трата стопа майнинга/терминолога вне потолка и переигрывается | ⚠ **НЕ вылечено, названо.** Резать её объёмом семантически неверно (не единица доставки); место — в ЦЕНЕ, платформенная сторона |
| 9 | Форма конвейера, где «юнит» — два объекта | ✅ `planVolume` отказывает громко; тест + мутация |
| 10 | `rebill_basis: failed` рядом с посчитанной цифрой | ✅ решение вынесено в чистую `rebillOutcome`; табличный тест на 5 состояний + мутация |
| 11 | ⛔ Потолок тратил покупку на переделку вперёд доставки | ✅ доставка-вперёд; ратифицировано опусом (порядок безопасен, A/B 6/6 против 4/6); тест + мутация на ТРОПУ ГНИЕНИЯ |
| 12 | Объёмный стоп не доезжает машинным каналом | ⚠ **НЕ вылечено, и это РАТИФИЦИРОВАННАЯ отсрочка:** промт §3.1 сам поместил различение в отчёт/лог, а не в поток; словарь потока — зона оркестратора, он сказал «не сейчас, строкой». Названо вопросом B |
| 13 | Ветка `proj.Rows == 0` не покрыта | ✅ `TestARunThatRePaysNothingIsNotAskedForConsent`; мутация падает |
| 14 | `export` не покрыт после правки банка | ✅ решено ОТКАТОМ export + пином осознанного расхождения |
| 15 | Нет теста на проводку `--max-units` через CLI | ✅ `TestTranslateWiresMaxUnits`; мутация «убрать проводку» падает |
| 16 | Предупреждение про майнящие книги не покрыто | ✅ тест на обе стороны (есть потолок / нет потолка) + мутация |
| 17,20 | Смешанное происхождение цифр в отказе гейта | ✅ каждое число из своей проекции; скоупная оговорка несёт скоупный ре-пин |
| 18 | Тройное повторение самого дорогого расчёта | ✅ мемо со сроком жизни БАНКА; запинена ИНВАЛИДАЦИЯ (корректность), не скорость |
| 19 | Капнутое согласие — дыра? | ✅ **РАТИФИЦИРОВАНО ЗДОРОВЫМ** опусом (согласие, не обход); по его же рекомендации запинено РАСКРЫТИЕ, которое этим согласием и делает |
| 21 | ⛔ Строка стопа сообщала ЮНИТЫ как ГЛАВЫ | ✅ движок говорит только «units»; тесты пере-пинены на верную формулировку |
| 22 | Отчёт называл несуществующий тест и снятые поля | ✅ таблица и §9 исправлены; отозванный пункт помечен ОТОЗВАННЫМ, а не переписан молча |
| 23 | Денominация: `rebill_units` (chunk×stage) против `--max-units` (юниты) | ✅ **РАТИФИЦИРОВАНО ДЕФЕКТОМ** опусом (замерено 15 против 6); аддитивное `rebill_output_units`, `rebill_units` НЕ переименован; тест + мутация |
| 24 | Семантика `export.ConfigDrift` изменена «за компанию» | ✅ ОТКАЧЕНО: чужое потребляемое поле не пере-определяется побочным эффектом; расхождение запинено, чтобы не «починили» обратно |
| 25 | Один документ `status` описывал ДВА банка | ✅ материализация помнит свои строки; счёт неподписанных — по тому же банку |
**Итого: 22 вылечены с тестом и мутацией, 3 названы и не вылечены** (7, 8, 12) — у каждой указан предмет и чей он.
#### ⚠ Мой рефактор сломал ШЕСТЬ упоминаний в чужом доке — я насчитала два и ошиблась в адресе
**ИСПРАВЛЕНО ПО ПРИЁМКЕ 29.08: первая редакция этого раздела была неверна дважды, и обе ошибки — метода, а не арифметики.** Оставляю разбор, а не переписываю молча.
**Ошибка 1 — счёт. Я насчитала ДВА сломанных якоря; сломано было ШЕСТЬ упоминаний** (те же два плюс четыре про `AttachRubyAliasesToManual`). Причина: я взяла число из `counts.py --lint`, **который по собственному объявлению content-сверяет только якоря С ТОКЕНОМ** («без токена — несверяемы: 728»). Эту его слепоту я в этой же сессии сама и процитировала — и всё равно приняла его счёт за полный. Правильный инструмент был на расстоянии одного грепа: `grep -n "seeding\.go"` по доку.
**Ошибка 2 — адрес. Я разрешала якорь по НОМЕРУ СТРОКИ, а надо по СОДЕРЖИМОМУ.** В старом файле на `:157` действительно лежала материализация базового банка, и я записала переезд туда. Но док в строке 76 говорил не о ней, а о `InjectivityCollisions` — warning-only диагностике, которая **никуда из `seeding.go` не уезжала**, а просто съехала со `157` на `64`. На `bankmaterialize.go:326`, куда я её отправила, лежит `r.baseMemory = membank.MaterializeBank(baseIn, …)` — к этому якорю отношения не имеющее. Номер совпал, предмет — нет.
**Верная таблица (сверено содержимым, каждая строка проверена грепом в дереве):**
| Что говорил док | Было | Стало |
|---|---|---|
| `InjectVoice is FALSE…` — условный фолд voice/address | `seeding.go:130-133` | `bankmaterialize.go:291-294` (текст перенесён ДОСЛОВНО) |
| `InjectivityCollisions` — warning-only диагностика dst-коллизий | `seeding.go:157` | **`seeding.go:64`** — из файла НЕ уезжала |
| `AttachRubyAliasesToManual`×4 упоминания | `seeding.go:43-50`, `:47` | `bankmaterialize.go:88-94`, сам вызов на `:92` |
Правку в доке делал оркестратор — `docs/architecture/` не моя зона. Мой вклад здесь: сломала я, а нашла и починила приёмка, потому что я доверилась инструменту вместо грепа и номеру вместо содержимого. **Урок годен шире этого случая: якорь разрешается тем, ЧТО он утверждает, а не тем, на какой строке лежал.**
#### §8. Obstacle — что НЕ удалось и что НЕ проверено
**0. ГЛАВНОЕ, и это НЕ вылечено: объёмный потолок на книге, которая МАЙНИТ банк, ломает вторую покупку.** Найдено денежной линзой, **воспроизведено дважды независимо** (линзой и опровергателем, каждым на своей копии репозитория, с контролем).
Механика: обрезанная покупка черновит часть книги, а стоп майнинга получает НЕобрезанный список чанков (`waverun.go:154` передаёт весь `chunks`) и досыпает `dst` по мере появления новых банкнот. Значит авто-банк РАСТЁТ от покупки к покупке; следующая покупка сворачивает его в обогащённый банк, edit-снапшот съезжает, а edit-джобы ПРЕДЫДУЩЕЙ покупки к нему пришпилены. Джоб-гард (`stagerun.go:47-59`) банк-онли-движение не отличает от смены конфига и падает **нетипизированной** ошибкой ⇒ **exit 1, платформа пишет `failed`** — вместо задуманного «завершение, код 0». Контроль (без `--max-units`, книга целиком за один прогон, потом резюм) проходит чисто — то есть путь открывает именно обрезание.
**Поправка опровергателя, важная и снижающая цену:** `--resnapshot` пере-оплату НЕ вызывает в общем случае — после подъёма снапшота джоба работает обычный ре-пин по `content_hash`, и уже отредактированные главы возвращаются **за $0**; платит только та глава, в тексте которой новый терм реально встречается.
**ПОПРАВКА к моей же оценке, найдена оркестратором при приёмке и проверена мной по коду — я ЗАНИЗИЛА область.** Я написала «платформа `--resnapshot` уже передаёт, флагманский путь цел, бьёт по ручному вызову». Неверно: платформа передаёт его НЕ всегда, а под условием `book.BankMoved` (`platform/internal/runs/runs.go:288`, читал не правил), и этот флаг ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит (`platform/internal/pgstore/books.go:985-993`: «a correction landed»). Значит бьёт именно по ФЛАГМАНСКОМУ пути: майнящая книга, вторая покупка, правок не было ⇒ флага нет ⇒ гард ⇒ `failed`. **Решение оркестратора: механизм лендится, но ПРОВОДКА `--max-units` на платформе гейчена до лечения.** **Смягчение, которое я сделал:** `planVolume` при живом майнинге пишет громкий WARN, называя и эффект, и `--resnapshot`. **Чего я НЕ делал:** не трогал джоб-гард — это ратифицированный Р6-контур и та самая мина D39.165 §3 / эрраты 28.08-и, её лечение назначено платформенной половине. **Заказывай отдельно, если хочешь, чтобы банк-онли-движение джоб-гард пропускал сам.**
**1. Стоп майнинга и терминолог тратят ВНЕ объёмного потолка — и переплачиваются каждой покупкой.** Тоже подтверждено. Мой пункт был мягче («потолок режет юниты, книго-уровневые вызовы — только деньги»); линза дожала: кандидатное пространство терминолога зависит от набора уже отчерновленных чанков, поэтому между покупками оно МЕНЯЕТСЯ, request_hash другой, и чекпойнты не переигрываются за $0. Книга, проданная десятью покупками, платит **десять** книжных консолидаций там, где цельный прогон платит одну. Ограничивают это только `gates.terminology.budget_usd` и `--ceiling-usd`. **Не чинил:** резать терминолога объёмом семантически неверно (он не единица доставки), а переносить его за пределы обрезанного прогона — это пере-проектирование майнинга (родня строки 203(з)). **Обязан быть в цене продажи, если книга майнит.**
2. **Живого прогона с провайдером НЕТ.** Всё исполнение — против фейкового провайдера батареи. Ни одного платного вызова я не делал и не был на это уполномочен. Значит: цифры «2 вызова на юнит» и «$0.00182 за юнит» — свойства фикстуры, а не рынка; поведение потолка на реальном материале не мерено.
3. **Прогон, обрезанный по объёму и упёршийся в подписной стоп банка, уходит с кодом 3**, и `res.Volume` тогда не докладывается: остановка принадлежит банку, а не потолку. Поведение корректное, но пара «стоп банка + объёмный потолок» отдельным тестом НЕ покрыта.
4. **Платформенная половина не сделана и не заказана мне** (§0 промта): `--max-units` в argv никто не передаёт. Пока платформа не научится считать `главы → юниты` по манифесту и передавать флаг, обещание «купил N глав — получил N глав» НЕ выполнено. Механизм есть, трубы нет.
5. **`storeOrder` доказан на моей фикстуре, а не на всех входах.** Тождество опирается на: `UNIQUE (book_id, src, sense, since_ch, until_ch)` (`store/migrate.go:203`) ⇒ префикс ключа не даёт ничьей, и на отсутствие `COLLATE` в схеме ⇒ побайтное сравнение TEXT. Оба факта проверены грепом и чтением DDL. Чего я НЕ проверял: поведение при значениях с невалидным UTF-8 и при типовой аффинности SQLite, если в TEXT-колонку когда-нибудь ляжет число.
6. **Порог согласия (`rebill_consent_usd`) я НЕ трогал** — сужена только СУММА. Если владелец считает, что при объёмной покупке должен ужиматься и порог, это отдельное решение.
7. Батарея прогнана на `make battery`; **`make battery-stand` (корпусные тесты) НЕ гонял** — нет стендовых данных на этой машине.
#### §9. Вопросы оркестратору — пингом, не интерпретацией в свою пользу
1. ⛔ **Продавать ли объёмом книги, которые МАЙНЯТ банк — до отдельного заказа?** Obstacle-0: вторая покупка на такой книге падает `exit 1`/`failed` без `--resnapshot`, и это открывает именно обрезание (контроль без потолка проходит чисто). Платформа `--resnapshot` уже передаёт (D39.166), значит флагманский путь цел, но ручной вызов — нет, и цена покупки на майнящей книге включает лишнюю книжную консолидацию терминолога (Obstacle-1). **Мой совет:** либо не ставить `--max-units` на майнящих книгах до заказа по джоб-гарду, либо принять оба хвоста явно. Решать тебе — я поставил громкий WARN и не стал трогать ратифицированный Р6-контур.
2. **Форма §3.2 отличается от той, что назвала строка 231.** Строка предписывает «движковый глагол ЛИБО флаг». Я не сделал ни того, ни другого: я починил СВЁРТКУ, потому что слепое окно было не в том, что `status` пишущий, а в том, что он сворачивал последний прогон вместо ближайшего. Это дешевле обоих кандидатов и ближе к уже ратифицированной двери D39.165 §3 («платформа читает смету из УЖЕ существующего `status --json`»). **Нужна ратификация формы и правка формулировки строки 231.**
3. **Поля шва.** `rebill_units`/`rebill_usd` потеряли `omitempty`, добавлено `rebill_basis`; `status_version` оставлен `tm-status-v1` (правка АДДИТИВНАЯ, потребителей у полей сейчас нет — они сняты с аллоулиста). Строка 231 обещает их возврат «вместе с глаголом» — глагола нет, значит возвращаются **вместе с этим фиксом**. Нужен контрактный минор и ре-аллоулист трёх полей. ⚠ **Уточнение от платформенной сессии `textmachine-c0`, сверено ею по коду:** пара снята с аллоулиста паком P10 **по ТАЙМИНГУ** (`platform/internal/ingest/resync.go:37-43`) — `status` проецировал СОХРАНЁННУЮ память, поэтому сразу после apply честно отдавал ноль, — а не «за отсутствием потребителя», как я написала сначала. Это ровно та причина, которую мой §3.2 и снимает. Риска для шва при этом нет: `DecodeStatus` (`resync.go:76`) — обычный `json.Unmarshal` без `DisallowUnknownFields`, поэтому новое поле её сторона просто не заметит. Возврат — слово оркестратора, он берёт минор на себя (пойдёт вместе с `session_ended`, канон 0.8.0).
4. ⛔ **ЭТОТ ПУНКТ ОТОЗВАН МНОЮ ЖЕ — подтверждать его НЕ надо.** Здесь стояла просьба подтвердить сужение СУММЫ гейта D20.2-Q2 объёмным скоупом с доводом «инертно без флага». Довод верный и НЕважный: с флагом каллер сам выбирает размер прогона, поэтому дробление обходило порог и пере-оплачивало книгу целиком, ни разу не спросив согласия (замерено: 30 глав, четыре прохода по $0.003640 при пороге $0.005460). **Действующее решение — второй круг, находка 1:** порог судится по ВСЕЙ книге; именованный кап `--accept-rebill=X` — по тому, что заплатит ЭТОТ прогон; прогон, не пере-оплачивающий ничего, гейта не встречает. Подтверждать надо ЭТО.
5. **`redrive` я флаг НЕ дал** (отказ по правилу «команда, которая на флаг не действует, его отвергает»). Соседняя правда, которую называю строкой, а не чиню: `redrive` внутри зовёт `TranslateBook` по ВСЕЙ книге (`status.go:926`), то есть у него своя денежная неожиданность — она была и до пака.
6. **Сигнал объёмного стопа в ПОТОК событий я не завёл.** По правилу `runevents.go:29-40` новый тип события — это минор, и читатель незнакомые типы игнорирует; но это твоя зона и контрактный минор, а §3.3 его в пак не клал. Если он нужен платформе — заказывай отдельно.
#### Вопросы оркестратору после второго круга — новые и изменившиеся
**A. Разблокировка майнящих книг — есть дешёвый вариант, но решение продуктовое, поэтому НЕ беру.** Причина поломки второй покупки в том, что стоп майнинга досыпает авто-банк между покупками. Если на прогоне с `--max-units` стоп майнинга НЕ запускать вовсе, авто-банк не растёт, edit-снапшот не двигается, вторая покупка идёт чисто, и хвост №1 исчезает без правки ратифицированного джоб-гарда. Цена ровно одна и она честная: **книга, продаваемая по частям, не майнит банк, пока её продают** — майнинг случается на цельном прогоне. Это размен «продавать по главам ИЛИ майнить», а не техническая деталь, поэтому его выбираешь ты. Если скажешь «да» — это несколько строк и тест.
**B. Различение доставки и переделки сейчас ЧИТАЕМО ЧЕЛОВЕКОМ, но не машиной.** `VolumeStop` несёт `Delivered`/`Reworked`/`LeftFresh`/`LeftRework`, и CLI это печатает — но платформа читает поток событий и код выхода, а туда различение не идёт. Значит покупка десяти глав, целиком ушедшая в переделку, для платформы по-прежнему выглядит как успешная доставка десяти. Полное лечение — поле в терминальном кадре потока, то есть твой контрактный минор (ты уже сказал «не сейчас, строкой»). Пишу это ещё раз потому, что после второго круга это перестало быть косметикой: именно здесь пере-оплата всё ещё может продаться как доставка, просто теперь молча на проводе, а не громко в отчёте.
**C. Порог согласия остаётся КНИЖНЫМ, и это теперь несущее решение, а не умолчание.** После отзыва сужения порог судит всю пере-оплату книги. Следствие, которое надо принять явно: **любая** покупка через `--max-units` на сдвинутом банке встречает гейт и требует `--accept-rebill`. Платформа его передаёт (при `BankMoved`), так что путь рабочий, но «тихих» пере-проходов больше не будет ни одного — и это ровно то, чего требует Р6. Если владелец захочет, чтобы мелкие покупки проходили без вопроса, это НЕ порог надо двигать, а заводить учёт уже-данного согласия — отдельный механизм, отдельный заказ.
**D. Проводка `--max-units` гейчена тобой — я это поддерживаю и после второго круга поддерживаю сильнее.** Пока различение доставки и переделки не доезжает до платформы машинным каналом (пункт B), платформа не может отличить «купил 10 глав и получил 10» от «купил 10 глав и получил 10 переделок». Механизм готов, труба — нет, и открывать её раньше пункта B я бы не стала.
#### Дополнение к вопросам после третьего круга
**E. Три развилки закрыты РАТИФИКАЦИЕЙ, а не моим словом — фиксирую, чтобы ты не пере-решал их заново.**
— «капнутое согласие» — ЗДОРОВО (согласие, а не обход; раскрытие теперь запинено);
— «денominация» — был ДЕФЕКТ, вылечен аддитивным `rebill_output_units`;
— «доставка вперёд переделки» — ЗДОРОВО, порядок безопасен (байты вне-очередного юнита дословно совпали с прямым прогоном), выигрыш замерен A/B (6/6 против 4/6).
Если пере-решаешь — пере-решай явно: под каждым лежит исполнение, а не рассуждение.
**F. Аллоулист: полей теперь ЧЕТЫРЕ, и четвёртое обязательно.** К `rebill_units`/`rebill_usd`/`rebill_basis` добавилось **`rebill_output_units`**. ⚠ **Возвращать `rebill_units` БЕЗ него нельзя**: `rebill_units` считает `chunk×stage`, все прочие «units» документа — выходные юниты, отношение внутри одной книги непостоянно, и платформа, отмерившая пере-проход по `rebill_units` и передавшая это число в `--max-units`, купит кратно больше книги. Ратификатор измерил: `rebill_units: 15` двумя строками под `total_units: 6`.
**G. Про умолчание «доставка вперёд переделки» — прошу зафиксировать словом, а не молчанием.** Покупка, ЦЕЛЬЮ которой был пере-проход, теперь потратит грант на недоставленные главы, если они есть. Ратификатор показал, что дверь пере-прохода этим не ломается (на дочитанной книге доставка-вперёд И ЕСТЬ порядок книги), но на ЧАСТИЧНО доставленной книге разница видна. Если продукту нужен режим «только переделывать» — это флаг, и это твой заказ, не мой.