Land the pack that lets a bank correction reach translated text: the run no longer dies on the snapshot guard and a re-pass can be bought

This commit is contained in:
heaven 2026-08-28 20:52:07 +03:00
parent 40a649cfcb
commit 7d45e24cb6
31 changed files with 1303 additions and 70 deletions

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
|---|---|---|
| Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE |
| Бэкенд | активного НЕТ | пак «тихая порча» ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.164**): инъекция банка больше не теряется на провайдере с одним системным слотом, дыра выдачи видна читателю В ТЕКСТЕ, повтор принятого документа сходится. Промт — в `archive/prompts/`. ⚠ Заказ §3.2 был ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя (маркер существовал и врал), а подсказка промта про место правки ОТКЛОНЕНА им с грунтом — оба решения ратифицированы. Свободные строки зоны: **228** (алиас возвращает отклонённую поверхность), **230** (размен сходимости), **141**-остаток, **131**. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
| Платформа | [PLATFORM_P10_SESSION_PROMPT.md](PLATFORM_P10_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак **P10** — основание D39.165. Мина под правкой банка (продолжающий прогон падает на гарде снапшота) + дверь пере-прохода (продать «улучшить переведённое» нечем, а движок это умеет). Оба рубежа пройдены: механический (поймал фантомную D-ссылку) и опровергатель — **8 находок, все применены**, включая две фатальные: одного `--resnapshot` не хватает (флагманский кейс кончался бы `failed` после взятого холда) и мой запрет `rebill_usd` через шов был неверен (D39.84 — про проекцию в API, не про шов). ⚠ Мина СУЖЕНА эрратой 28.08-и. Пак **P9** отработан и заленджен (D39.162), промт — в `archive/prompts/` |
| Платформа | активного НЕТ | пак **P10** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.166**) вместе с минором **0.7.0**: мина под правкой банка обезврежена, пере-проход можно КУПИТЬ. Промт — в `archive/prompts/`. ⚠ **Первая сдача пака была НЕВЕРНОЙ**, и это нашёл ШИРОКИЙ самопроход сессии (заказан владельцем 28.08, канон дополнен `40a649c`): три фатальные находки, включая «мина, объявленная обезвреженной, стоит». Две ошибки оркестратора вынесены эрратой 28.08-к. Следующая работа зоны — кодовый пак по 23 открытым строкам P8-REVIEW либо эпоха смены формы конвейера (`PD-403`/`PD-404`, D39.165 §2) |
| Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) |
| Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале |
| Контракт | активного НЕТ | минор **0.6.0** ПРИНЯТ и заленджен 28.08 (**D39.162**/**D39.163**): полоса прогресса объявлена сквозной, `Progress.stage` заведён ВТОРЫМ ограниченным исключением из границы «ничего о том, КАК переводится книга». До него — **0.5.0** (D39.161, дверь правок банка). Хвост компаньона — строка **203**, следующий минор по её пункту (к) |

View file

@ -1,4 +1,4 @@
# Реестр D-нот — карта актуальности v2 (D1D39.165;
# Реестр D-нот — карта актуальности v2 (D1D39.166;
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
@ -226,4 +226,5 @@
| D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ |
| D39.164 | 28.08 | **Бэкенд-пак «тихая порча» принят и заленджен**: ось `SystemMessages` склеивает системный ряд у провайдера с одним слотом (инъекция банка перестала теряться), дыра выдачи видна в потоке текста, порядок записи решений сменён на rejects-first и повтор документа сходится. Приёмка — четырьмя посадками оркестратора, все пойманы топично. Заказ §3.2 пере-формулирован находкой исполнителя (маркер существовал и ВРАЛ), подсказка промта про место правки отклонена с грунтом и это ратифицировано. Строки 208 и 193 закрыты, 141 наполовину; заведены 228/229/230. | ✅ |
| D39.165 | 28.08 | **Три продуктовых развилки сняты словом владельца.** (1) Единица продажи: цена от ОБЪЁМА исходника — носитель `chapters.units_total`, известен на интейке за $0; потолок объёма в ДВИЖКЕ рядом с денежным; хвост качества в тариф и мерить; константу по сегодняшним числам НЕ калибровать (гейт — строка 202). Измерено: реальная глава $0.0115$0.0190 против константы $0.03, «купить N глав» — денежная ручка с подписью «главы». (2) Смена формы конвейера — СОБЫТИЕ КНИГИ («эпоха»), канон уже разрешает пересчёт через границу `structure_version`; монотонность флага не отменяется. (3) Продажа пере-прохода: дверь к уже построенному движковому механизму, смета из `status --json` без долларов; ⚠ МИНА — первый продолжающий прогон после правки банка УПАДЁТ без `--resnapshot`, чинить первым. Область D39.144 п.1 сужена явно. | ✅ |
| D39.166 | 28.08 | **Пак P10 принят и заленджен + контрактный минор 0.7.0**: мина под правкой банка обезврежена, пере-проход продаётся членом `RunRequest.re_pass`, согласие даётся ДЕНЬГАМИ (потолок = холд), полоса — «одна неделимая единица работы». ⚠ Первая сдача пака была НЕВЕРНОЙ: широкий самопроход сессии (заказан владельцем) нашёл три фатальные — мина СТОЯЛА, $0-цена муровала дверь, глава-полоса мертва. Две ошибки оркестратора вынесены эрратой 28.08-к. Пересборка сессии лучше заказа (факт от своей квитанции, фондированный консент). Новый класс: предложение канону, пережившее пересборку кода, — гипотеза, пока не сверено с деревом. | ✅ |

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.165 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
# Журнал решений оркестратора — контракт D1D39.166 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
@ -19,6 +19,7 @@
> ⚠ **Эррата 27.08-ж (D39.160 п.2) — ошибка ОРКЕСТРАТОРА, найденная исполнителем пака.** Нота утверждала, что после сноса отменённой двери «канон и деплой СОВПАДУТ точно», а промт минора (§3.2-бис) — что счётчики `pending_decisions`/`complete` «навсегда нули». **Верно по ПУТЯМ, неверно по ПОЛЯМ:** проекция `GET /bank` продолжает их слать, и это не нули — `bankCountsTx` (`platform/internal/pgstore/readmodel.go:330`) считает `proposed`-строки, о чём говорит её собственный комментарий. Клиент 0.5.0 лишние поля игнорирует, но аллоулист-норма нарушена до монтажа (2в). Носитель — `PD-399`. ⚠ Контрактная сессия принесла это ПИНГОМ по §11 промта, вместо того чтобы тихо подогнать работу под неверную посылку; это и есть поведение, которого норма требует.
> ⚠ **Эррата 27.08-з (D39.156, состав пункта 2в): «воркер решений → глагол перед возобновлением» СНЯТ — посылка изменилась.** Пункт писался, когда двери в контракте не было и подразумевалось НАКОПЛЕНИЕ: платформа копит решения у себя и скармливает их движку перед `resume`. С дверью канона 0.5.0 накопления не существует — правка ПРИМЕНЯЕТСЯ в момент подачи, и гарантия «до возобновления» у синхронной формы СИЛЬНЕЕ воркерной: применено прежде, чем клиент получил `200`. Проверено исполнением с обеих сторон: движок на стопе ВЫХОДИТ (`cmd/tmctl/main.go:95`, код 3), флок не-блокирующий и отпускается ядром на выходе процесса (`store/store.go:187`), между стопом и возобновлением живого процесса на проекте нет — запинено `pipeline/bankchain_test.go:62-64`; кап 5000 решений выведен ИМЕННО из синхронности («the call stops fitting the caller's timeout»). Остаётся не воркер, а пер-книжная сериализация в обработчике. `Resume` «с решениями как они есть» не тронут.
> ⚠ **Эррата 28.08-и (D39.165 §3, размер мины) — ошибка ОРКЕСТРАТОРА, найденная опровергателем промта P10.** Нота утверждает: «первый же ПРОДОЛЖАЮЩИЙ прогон после первой же правки банка УПАДЁТ». **Переоценено.** Гард снапшота стреляет по СУЩЕСТВУЮЩЕМУ джобу (`backend/internal/pipeline/stagerun.go:47``EnsureJob` создаёт джоб стадии в момент, когда стадия впервые исполняется), а правка в ГЛАВНОМ окне — стоп подписи `awaiting_bank` — двигает edit-снапшот, когда edit-джобов ЕЩЁ НЕТ: возобновление создаёт их свежими, и гард молчит. Драфт-волна mined-строк не видит вовсе (`seeding.go:143-153`). **Дефект СТОИТ, но его триггер уже: правка, сделанная ПОСЛЕ появления edit-джобов** — пауза потолком посреди редактуры и ДОЧИТАННАЯ книга. ⚠ Срочность при этом НЕ падает: флагманский случай продукта («поправил имя героя в дочитанной книге») — ровно тот, где edit-джобы существуют, то есть мина бьёт именно по нему. **Цена ошибки была бы прямой:** репро на потоке `awaiting_bank` показало бы ЗЕЛЕНЬ без фикса, и пак мог быть отозван как мнимый. Промт P10 §4.2 исправлен: репро обязано фиксировать состояние «edit-джоб существует ДО правки».
> ⚠ **Эррата 28.08-к (D39.165 §3 + решение оркестратора о глава-полосе) — ДВЕ ошибки, обе найдены широким самопроходом платформенной сессии, обе доказаны исполнением.** **(1) Посылка «смета УЖЕ публикуется в `status --json`» верна только ПОСЛЕ свёртки.** `bank-apply` пишет только ФАЙЛЫ решений, а `status` считает ре-билл от СОХРАНЁННОГО глоссария (`backend/internal/pipeline/status.go:733-744`, `projectStoredMemory`его собственный комментарий: «A seed-FILE edit not yet re-run is NOT reflected here… that drift surfaces on the next translate's re-seed»). Свёртка происходит внутри СЛЕДУЮЩЕГО `translate`, поэтому сразу после правки движок отвечает `units=0`/`drift=false`. Следствие: продажа «затронуто N юнитов» и холд от сметы В ТЕКУЩЕМ ШВЕ НЕДОСТИЖИМЫ — для них нужен движковый глагол «свернуть банк и оценить ВНЕ translate», которого нет. **(2) Решение оркестратора «полоса пере-прохода — в ГЛАВАХ» ОТМЕНЯЕТСЯ: его посылка опровергнута.** Я рассудил, что $0-репин двигает полосу, потому что идёт через тот же `resumeFromChunkStatus`, — и не проверил анонс. Движок анонсирует юнит ОДИН РАЗ на жизнь книги (announce-once, `backend/internal/pipeline/events.go:49,143-162`), пере-проход не ре-анонсирует ни репины, ни пере-переводы ⇒ `done` остался бы НУЛЁМ навсегда. Это ровно тот класс, от которого предостерегает памятка «не выводить из соседнего механизма, не проверив свой». ⚠ **Что при этом НЕ отменяется:** запрет класть ЮНИТЫ в поле, объявленное в главах, стоит — но объявленная в каноне «одна единица работы» запретом не является, потому что она НЕ молчаливая.
> ⚠ **Навигация (актуализация 07.08, эра D39.1xx):** append-only-дисциплина (D23.3) означает, что
> НЕВЕРНЫЙ ФАКТ внутри старой ноты не переписывается, а получает эрратау — и тогда он опасен ровно
@ -1032,3 +1033,58 @@ device». Починено `TestMain`, проверено двумя прого
### ПОРЯДОК РАБОТ, вытекающий из решений
**Мина и дверь — ОДИН пак:** оба закрываются теми же двумя флагами в одной функции сборки аргументов. Дальше — строка **202** (живой прогон текущего стека), потому что без неё цена не калибруется. Эпоха формы конвейера и потолок объёма — параллельно, они ни от чего не гейчены.
## D39.166 — ПЛАТФОРМЕННЫЙ ПАК P10 ПРИНЯТ И ЗАЛЕНДЖЕН + КОНТРАКТНЫЙ МИНОР 0.7.0: мина под правкой банка обезврежена, пере-проход можно КУПИТЬ (28.08, оркестратор №19). ✅
**Что заленджено.** Продолжающий прогон по книге с применённой правкой банка больше не умирает на
движковом гарде снапшота · пере-проход продаётся членом `RunRequest.re_pass` (канон **0.7.0**) ·
согласие на пере-плату даётся ДЕНЬГАМИ (потолок = холд прогона) · полоса такого прогона объявлена
формой «одна неделимая единица работы».
⚠ **ГЛАВНОЕ СОБЫТИЕ ЭТОГО ПАКА — НЕ КОД, А ТО, ЧТО ЕГО ПЕРВАЯ СДАЧА БЫЛА НЕВЕРНОЙ И ЭТО НАШЛА САМА
СЕССИЯ.** Пак был сдан, числа сходились, батарея была зелёной — и я собирался его лендить. По
требованию владельца (28.08) сессия провела ШИРОКИЙ адверсариальный проход по своей готовой работе:
6 линз, 42 находки, **три фатальные**, все доказаны исполнением на настоящем движке.
1. **Мина, объявленная обезвреженной, СТОЯЛА.** `bank-apply` пишет только ФАЙЛЫ решений, а `status`
считает ре-билл от СОХРАНЁННОГО глоссария (`backend/internal/pipeline/status.go:733-744`, чей
собственный комментарий это и говорит) — свёртка происходит внутри СЛЕДУЮЩЕГО `translate`. Значит
сразу после правки движок отвечает «ничего не двигалось», факт не взводился, флаги не выдавались.
Живой тест сессии дёргал движковый гард НАПРЯМУЮ и платформенную цепь не покрывал — класс, о
котором она сама же предупреждала в том же отчёте.
2. **$0-цена мурует дверь:** `rebill_usd` у движка `omitempty`, юниты по нулевой цене приходят без
цены, и отказ «нет цены» превращал ЛЕГШУЮ правку в вечный ре-сенд.
3. **Глава-полоса МЕРТВА** — см. эррату 28.08-к.
**ДВЕ ОШИБКИ ОРКЕСТРАТОРА, обе вынесены эрратой 28.08-к, обе найдены этим проходом.** Посылка
D39.165 «смета уже публикуется в `status`» верна только ПОСЛЕ свёртки. И моё решение «полоса
пере-прохода в ГЛАВАХ» опрокинуто: я вывел «репин двигает полосу» из того, что он идёт общим путём
резюма, и НЕ проверил анонс — движок анонсирует работу один раз за жизнь книги. Вывод из соседнего
механизма без проверки своего.
**ПЕРЕСБОРКА СЕССИИ ЛУЧШЕ МОЕГО ЗАКАЗА, и это записано как есть.** Факт «банк двигался» берётся из
СОБСТВЕННОЙ квитанции двери (`changed` либо `already_applied` — сходимость ретрая держится
состояниями), а не спрашивается у движка: спрашивать было моей ошибкой. Гашение факта — ЯВНОЕ и
только чистым финишем, с названной в коде асимметрией: застрявший факт стоит одного безвредного
`--resnapshot`, ложно снятый — смерти на гарде. **Консент ФОНДИРОВАННЫЙ** — согласие даётся
деньгами, которые пользователь уже внёс, и потолок равен бюджету; это удовлетворяет мой же ⛔
«согласие обязано быть явным», ничего не выдумывая.
**Приёмка — исполнением.** Батарея пере-прогнана мной с живым Postgres: 18 пакетов, `RUN=803`, FAIL
0, гонок 0; гейт версии краснел предсказуемо и позеленел после подъёма константы. Посадил ДВЕ свои
мутации: «полоса снова из глав» роняет `TestARePassRunsBarIsOneUnitOfWork`, «гашение без чистого
финиша» роняет `TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt`обе топично.
**ПРИЁМКА ПОЙМАЛА ЕЩЁ ОДНО, И ЭТО НОВЫЙ КЛАСС.** Предложение сессии по контрактной половине
ПРОТУХЛО ОТ ЕЁ ЖЕ ПЕРЕСБОРКИ: оно описывало поле сметы, глава-полосу и код отказа, которых после
пересборки не стало. Прими я его переносом — на провод уехало бы поле БЕЗ ПРОИЗВОДИТЕЛЯ, ровно тот
класс, который этот же пак вычищает в другом месте. **Формулировка урока (её же): предложение
канону, пережившее пересборку кода, — тоже гипотеза, пока не сверено с деревом.** Парный класс к
«фейк ходит мимо шва», только в доках.
**Названная цена минора.** Смету «затронуто N юнитов» до покупки провод НЕ несёт — в текущем шве она
недостижима. Возвращается вместе с движковым глаголом «свернуть банк и оценить вне прогона»
(заведён строкой бэклога). Поля `rebill_units`/`rebill_usd` сняты из аллоулиста шва: поле без
потребителя — тот самый класс дефекта, и основание, по которому они брались, снято эрратой.
**Строки.** Мина строки **199**(б) ЗАКРЫТА. Строка **215** закрыта в части двери; отложенная
половина — смета — переехала в новую строку. Заведена строка на движковый глагол сметы.

View file

@ -51,14 +51,16 @@ cmp-сверка обязательна (D39.138 п.3).
> сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML;
> при вопросе «почему так» — этот файл.
>
> **Статус: РАТИФИЦИРОВАН по 0.6.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
> **Статус: РАТИФИЦИРОВАН по 0.7.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) ·
> 0.2.2 (D39.129, 10.08) · 0.2.3 (D39.135, 15.08) · **0.3.0 (D39.138, 16.08) — ломающий минор по
> целостному ревью research/28** · **0.4.0 (D39.152, 20.08) — синк с платформой, §6в** ·
> **0.5.0 (D39.161, 27.08) — снос отменённой пер-термной модели подписи (PD-370) + дверь правок
> банка, выведенная из построенного глагола движка (D39.158), + предупреждение о конверте вне
> `/v0`; вывод и провенанс — §2.19, отчёт сессии — `docs/CONTRACT_MINOR_REPORT.md`** ·
> **0.6.0 (D39.162, 28.08) — сквозная полоса прогресса и подпись стадии, приехавшие ОДНИМ лендингом
> с платформенным паком P9; вывод и провенанс — §2.20**. Дом канона — этот каталог;
> с платформенным паком P9; вывод и провенанс — §2.20** ·
> **0.7.0 (D39.166, 28.08) — покупка ПЕРЕ-ПРОХОДА: правка банка доезжает в уже переведённый текст;
> провенанс — §2.21**. Дом канона — этот каталог;
> `frontend/docs/api-contract/openapi.yaml` — байт-зеркало.
>
> ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его
@ -876,6 +878,42 @@ the original request was never applied». Каждый `POST /books` созда
---
### 2.21. Покупка пере-прохода (0.7.0) — ✓ выведено из построенного механизма движка
**Что появилось.** `RunRequest.re_pass` — булев член; `ceiling_chapters` вышел из `required` и
объявлен взаимоисключающим с ним. Плюс объявленная форма полосы такого прогона и ограничение фразы
«finished work is not bought twice».
**Зачем.** Главный пользовательский цикл продукта — «поправил термин → правка доехала в уже
переведённый текст» — до 0.7.0 был НЕВЫРАЗИМ на проводе: у дочитанной книги остаток нулевой, шкала
даёт максимум 0, валидного `ceiling_chapters` не существует физически. Канон при этом обещал, что
правка «takes effect on the NEXT run», а следующего прогона купить было нечем — **две фразы канона
складывались в дедлок**, и 0.7.0 его снимает.
**Провенанс — движок, а не проектирование.** Механизм построен и ратифицирован задолго до этого
минора: `translate --resnapshot` определяет затронутые правкой юниты байт-сверкой отрендеренного
запроса, незатронутые пере-привязывает за $0, а согласие на пере-плату берётся флагом
`--accept-rebill`. Минор не изобретает механику — он открывает к ней дверь. ⚠ Проектирование самого
продуктового ЦИКЛА пост-ридинга (отдельная ручка «перегенерировать», dry-run, доезд правки до
черновика) остаётся гейченным полигоном; область D39.144 сужена явно D39.165.
**Чего минор НЕ объявляет, и это названная цена, а не забывчивость.** Смету «затронуто N юнитов»
ДО покупки провод не несёт. Причина установлена исполнением и стоила пересборки пака: `bank-apply`
пишет только ФАЙЛЫ решений, а движковый `status` считает ре-билл от СОХРАНЁННОГО глоссария
(`backend/internal/pipeline/status.go:733-744`его собственный комментарий предупреждает, что
правка файла, ещё не прогнанная, здесь не отражается). Свёртка происходит внутри СЛЕДУЮЩЕГО
`translate`, поэтому сразу после правки движок честно отвечает «ничего не двигалось». Смета вернётся
на провод вместе с движковым глаголом «свернуть банк и оценить ВНЕ прогона» — до тех пор согласие
даётся ДЕНЬГАМИ: потолок пере-прохода равен холду, который пользователь уже внёс.
**Форма полосы объявлена, а не выведена, и причина названа в самом каноне.** `total` = 1, `done` =
0 → 1 на чистом финише. Первое решение оркестратора («полоса в главах») ОТМЕНЕНО эрратой 28.08-к:
движок анонсирует работу ОДИН РАЗ за жизнь книги, поэтому пере-проход, который переделывает уже
анонсированное, новых анонсов не производит — глава-полоса стояла бы на нуле навсегда. Более дробная
полоса была бы полосой, которая не движется.
---
## 3. Зависимости: чтение → источник → строка бэклога
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки

View file

@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: TextMachine API
version: 0.6.0
version: 0.7.0
summary: Ratified contract between the frontend and the TextMachine platform.
description: |
**RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`;
@ -589,6 +589,12 @@ paths:
**Raising the limit of a stopped run is done by starting a NEW run** with a larger
`ceiling_chapters`: a paused book is startable, finished work is not bought twice, and the
new run continues where the old stopped. `resume` does not do this.
⚠ **"Not bought twice" bounds THIS path, not the whole surface.** A re-pass
(`RunRequest.re_pass`) deliberately returns to finished chapters — but it buys the DELIVERY
of a correction into them, not the work again: what the correction does not touch is re-used
at no cost, and only what it does touch is paid for. A book that is finished has no other way
forward, which is what the member exists for.
parameters:
- $ref: '#/components/parameters/ClientHeader'
- $ref: '#/components/parameters/IdempotencyKey'
@ -1274,7 +1280,7 @@ components:
The version this deployment serves — the only place a non-streaming client learns it. A
client generated against a different one REFUSES to work and says so: while the major is
`0` a differing minor carries breaking changes by design.
examples: ['0.6.0']
examples: ['0.7.0']
language_pairs:
type: array
description: |
@ -1355,6 +1361,13 @@ components:
failed. Reaching one is the shape of a COMPLETED purchase, not a promise about every run, and
a fraction below one is not an error to render.
⚠ **A RE-PASS run counts ONE INDIVISIBLE unit of work: `total` is `1`, and `done` is `0`
until it finishes clean, then `1`.** This is a declared shape, not chapters — do not render
it as "0 of 1 chapters". The reason is a property of the service, named rather than hidden:
it announces a piece of work ONCE over a book's life, so a re-pass, which re-does work
already announced, produces no new announcements to count. A finer-looking bar would be a bar
that never moves, and a bar that never moves is worse than an honest one that moves once.
⚠ **"Finished" means the work THIS deployment does on a chapter is finished** — the last pass
the book actually gets, whatever that is.
@ -2309,8 +2322,25 @@ components:
RunRequest:
type: object
required: [stop_for_signing, ceiling_chapters]
required: [stop_for_signing]
properties:
re_pass:
type: boolean
description: |
Buy a RE-PASS instead of new chapters: carry the corrections already recorded on the
book's memory bank into text that is already translated. `ceiling_chapters` is then
neither required nor legal — a re-pass buys no chapters, it walks the book the service
has already produced and re-does only what the corrections actually touch.
**Legal only while the book carries a correction the service has not yet walked in.**
Otherwise the start answers `409` with `cause.code: re_pass_unavailable` — either no
correction was recorded since the last run, or the one recorded touched nothing that was
paid for.
⚠ **What it costs is bounded by the hold like any other run**, and most of the work is
free: the parts of the book a correction does not reach are re-used, not re-bought. The
service does not quote the affected amount BEFORE the purchase — it cannot yet, and
saying otherwise would be a promise this deployment does not keep.
stop_for_signing:
type: boolean
description: |
@ -2325,8 +2355,13 @@ components:
Limit of THIS run, in chapters, within the bounds from
`GET /books/{bookId}/run-options`.
Required: a run started without a declared limit would spend past the boundary the user
is entitled to set BEFORE it begins rather than learn about after. `0` is not legal.
Required for an ordinary run, and a run started without a declared limit would spend
past the boundary the user is entitled to set BEFORE it begins rather than learn about
after. `0` is not legal.
⚠ **Absent — and refused — when `re_pass` is true:** a re-pass does not buy chapters, so
a chapter limit would describe nothing. The two members are mutually exclusive, and a
request carrying both is `400`.
RunOptions:
type: object
@ -2604,8 +2639,8 @@ components:
properties:
contract:
type: string
description: Contract version this deployment serves, e.g. `0.6.0`.
examples: ['0.6.0']
description: Contract version this deployment serves, e.g. `0.7.0`.
examples: ['0.7.0']
EventStatus:
allOf:

View file

@ -42,7 +42,7 @@
**(б) фикс-имена ратифицированной конвенции шва** — `events.jsonl` никто платформе не «сообщает», это константа
(`platform/internal/ingest/tail.go:17`=`const JournalFile = "events.jsonl"`, D39.106); без этого выноса буква ломает главный канал самого шва.
*Прецеденты:* потолок аргументом (D39.110 п.2б`--ceiling-usd`, D39.122) · `--verify-bank`
флагом (`platform/internal/runner/engine.go:90`=`func TranslateArgs(workdir string, verifyBank bool`) · дефолт `project_db` по конвенции каталога
флагом (`platform/internal/runner/engine.go:99`=`func TranslateArgs(workdir string, verifyBank bool`) · дефолт `project_db` по конвенции каталога
(`backend/internal/config/book.go:189`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")`) · конвенционное опциональное чтение `.auto-bank.yaml`
(`backend/internal/pipeline/mining.go:617`=`.auto-bank.yaml`).
@ -56,7 +56,7 @@
*Почему жёстко:* движок пере-читает сид ПОСРЕДИ прогона (`mining.go:221`), запись в живой прогон
въехала бы в снапшот недетерминированно. Потолок посреди прогона менять НЕЛЬЗЯ — он едет со
стартом и после не меняется, лекарство — новый прогон
(`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта;
(`platform/internal/runs/reconcile.go:1291`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта;
не выдумывать её заново из `research/25`.
3. **Каждый JSON-выход глагола несёт версию документа.** Сегодня асимметрия: `tm-bank-v1`

View file

@ -4,7 +4,7 @@
> Отчёт относит этот механизм к носителям решения D39.106 — в теле D39.106 его НЕТ вовсе (проверено грепом по слайсу
> живого тела ноты **D39.106** («ШОВ: прогон переживает деплой платформы») в `../architecture/05-decisions-log.md` — адресуем НОМЕРОМ, а не строкой: line-якорь в журнал решений не переживает вставку эрраты, и он уже съезжал дважды (гейт якорей, 28.08); первый хит темы — только в D39.110. ⚠ Испр. 23.08: прежняя редакция ссылалась на слайс `archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ — греп по нему подтвердил бы эррату вакуумно). Строка была КАНДИДАТОМ панели,
> а живой канон решил ось иначе: **потолок едет со стартом прогона и после не меняется**, лекарство при упоре — НОВЫЙ прогон
> (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого
> (`platform/internal/runs/reconcile.go:1291`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого
> кандидата, а не как обязательный механизм; закон входной двери шва (`../architecture/17-seam-inbound-law.md` п.2) это фиксирует:
> посреди прогона внутрь идёт ТОЛЬКО сигнал.

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,304 @@
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
## ПЕРЕСБОР P10 ПО ДИСПОЗИЦИЯМ ИСПОЛНЕН — форма БЕЗ сметы, все принятые корни закрыты, посадки 4/4 (сессия платформы, 28.08, после самопрохода ниже)
Диспозиции оркестратора (эррата 28.08-к; пересборка принята целиком; §3.2 — «до твоего холда»;
полоса — «одна единица работы»; resume — «купи заново»; якоря journal-доков не чинить до лендинга).
### Что легло (карта корней → лечение)
- **K1/K2/K5/K10/K11-острота — умерли вместе со сметой.** `recordBankMove`(со status),
`rebillConsent`, `rebillCentGuard`, `ErrRebillOutgrown`, сметные колонки books — УПРАЗДНЕНЫ;
миграция 00027 пересобрана (только `bank_moved_at` + консенты строки прогона), sha256
пере-подписан. Факт — от СВОЕЙ квитанции: `corrected(rec)` = `changed:true` ЛИБО хоть один
`already_applied` (ретрай-сходимость держится состояниями квитанции; провал записи →
`bank_corrections_incomplete`, ре-сенд дописывает). `tmctl status` НЕ зовётся ни в двери, ни в
Start, ни в Resume — «status — ремонт, не поллинг» восстановлен, ошибки движка больше не валят
допуск, пиннинг-вопрос (K9-бинарь) беспредметен.
- **K6/K8/часы — умерли вместе с временны́м предикатом.** Предикат факта = `bank_moved_at is not
null`; гашение ЯВНОЕ и только успехом: `reconcile.finish` при `l.Resnapshot && status=="ready"` →
`ClearBankMove` (вне транзакции finish НАМЕРЕННО: асимметрия «застрявший факт = один безвредный
`--resnapshot`; ложно-снятый = смерть на гарде» — комментарий в коде). failed/stopped/paused
оставляют факт → петля K6 разорвана; правка в окне `awaiting_bank` видна resume того же прогона
(K8) — лишний флаг безвреден по построению гарда (читается только при реальном сдвиге снапшота).
- **K7 — консент ФОНДИРОВАН**: `--accept-rebill=<холд ЭТОГО прогона>` (обычная покупка —
`Ceiling(C)`; resume — полный бюджет строки; re-pass — свой холд). Бланкет-оговорка (K11)
сужена до честной: кап не «различает» источники дрейфа — он ограничивает трату деньгами,
которые пользователь дал.
- **K4 — resume re-pass закрыт словом**: `ErrNotResumable` «a re-pass is bought again» ДО
reopen-арифметики (Ceiling(0)-ловушка недостижима); прерванный re-pass оставляет факт → повторная
покупка доступна (запинено).
- **§3.2 «до твоего холда»**: холд re-pass = `Ceiling(chapter_count)` — честный потолок «вплоть до
полного пере-перевода», незатронутое $0, разница released; гейт — только факт (`BankMoved`);
`BookRunContext` вырос полем `ChapterCount`.
- **K3 — полоса «одна единица работы»**: `runDone` C=0 → 0, и 1 при `finished_at∧ready`;
`runTotal` C=0 → литерал 1 (0/0 недостижим; `chapter_count`-мутабельность total'а умерла);
`stage='re_pass'`. Канонная оговорка — за оркестратором.
- **K12-live**: живой тест теперь гоняет ОБА флага против настоящего движка
(`--accept-rebill=0.030000` в проходной половине — движковый гейт принял).
- `Options.RebillUnits` снят (показ «N юнитов» отложен вместе со сметой — строка бэклога
оркестратора на движковый глагол).
### Пины и посадки пересбора
Переписаны/добавлены: `TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot` (+ветка already_applied
пере-штампует после ClearBankMove) · `TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn`
(`--accept-rebill=0.060000` = холд 2 глав) · `TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt`
(жизненный цикл факта через настоящий Sweep-путь) · `TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne`
(холд 150000 = вся 5-главная книга) · `TestAResumeOverAMovedBankGrantsTheConsents` (90000 = бюджет
строки) · `TestARePassIsBoughtAgainNotResumed` · `TestARePassRunsBarIsOneUnitOfWork` (0/1 → 1/1;
C=0 без консента отвергнут). Посадки: M-B2 (критерий квитанции мёртв) · M-C2 (гасит любой исход) ·
M-G (консент не фондирован) · M-H (ветка полосы снята → 0/0 пойман) — **4/4 топично**.
### Числа пересбора (командой)
`go test ./... -race -count=1 -v` с гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, RUN=803**,
FAIL/DATA RACE — 0; `golangci-lint`**0 issues**;
`gofmt -l` пусто. Опись: `git status --short -- platform/` — дифф СЖАЛСЯ против сданного
(упразднений больше, чем добавлений).
### Дописка: провод 0.7.0 смонтирован (четыре пункта приёмки)
1. `ContractVersion`**0.7.0**, гейт версии зелёный.
2. **`re_pass` на проводе**: `wireRunRequest.re_pass`; `ceiling_chapters` обязателен только для
обычной покупки; оба вместе → 400 malformed (канонное взаимоисключение); `StartRequest`
собирается по форме. Пин `TestARePassRequestIsItsOwnPurchaseShape` (три стороны: доходит до
сервиса как re-pass · оба вместе 400 · «нечего» → 409 со своим cause).
3. **`cause.code: re_pass_unavailable`** — свой код взамен временного `bounds_moved`
(`CauseRePassUnavailable`, маппинг `ErrRePassUnavailable`).
4. **`rebill_units`/`rebill_usd` СНЯТЫ из аллоулиста шва** (слово приёмки: поле без потребителя —
класс, который пак лечит; основание взятия снято эрратой 28.08-к) — вместе с декод-пином;
в шапке `StatusReport` осталось ИМЕНОВАННОЕ объяснение, почему пара не берётся (тайминг
свёртки) и с чем вернётся (движковый глагол сметы).
Батарея после провода (финальная этого раунда): `go test ./... -race -count=1 -v` с гейтами →
**EXIT=0, 18 пакетов ok, SKIP=0, RUN=803**, FAIL/DATA RACE — 0; линт **0 issues**; `gofmt -l` пусто.
### Остатки, названные честно
- ~~`rebill_units`/`rebill_usd` остаются в аллоулисте~~ — СНЯТО приёмкой (см. дописку выше):
пара убрана из шва целиком до движкового глагола сметы.
- Правки банка, сделанные в ОДНИ СУТКИ жизни P9-двери ДО деплоя P10, факта не имеют (миграционный
in-flight): их продолжение может поймать гард; лечение — повторный apply того же документа после
деплоя (byte no-op проставит факт по already_applied-ветке).
- Консент-гейт движка живьём деньгами по-прежнему не пробит ($0-цены; кандидат строки 202) — из
прежнего Obstacle, не изменилось.
## ⚠ ШИРОКИЙ САМОПРОХОД P10 (заказ оркестратора 28.08): ПАК В СДАННОЙ ФОРМЕ НЕСОСТОЯТЕЛЕН — 42 находки/6 линз, три корня валят ФОРМУ; диспозиция и СТОП до слова оркестратора (сессия платформы, 28.08)
Заказ «найди, где автор неправ» исполнен воркфлоу (6 линз: 1×Fable на деньги + 3×opus + 2×sonnet,
42 находки, 57 not_refuted; полные траектории — журнал wf_323e81c4-3e3). **Запись ниже — по норме
«дефекты, внесённые самим паком, первыми»; сданная выше запись «ПАК P10 ОТРАБОТАН» в части §3.1-мины
и §3.2-полосы ОПРОВЕРГНУТА этим проходом.** По дереву НИЧЕГО не менено после сдачи — пересбор по
диспозиции ниже требует слова оркестратора (два корня пересматривают его решения: D39.165-предпосылку
и главу-форму полосы).
### Корни (дедуплицировано из 42; K1-K3 — фатальные для формы)
| # | Корень | Улика |
|---|---|---|
| K1 | **СЛЕПОЕ ОКНО: смета в момент правки НЕ СУЩЕСТВУЕТ.** `tmctl status` считает дрифт/ре-билл от stored memory, а `bank-apply` пишет только файлы решений — свёртка происходит ВНУТРИ следующего translate. Сразу после apply живой движок отдаёт `rebill_units=0, config_drift=false` (исполнено агентами на настоящем tmctl, дважды подряд) ⇒ `recordBankMove` не пишет НИЧЕГО, факт не взводится, флаги не выдаются, **мина стоит**а мой live-тест проверял движковый гард НАПРЯМУЮ (TranslateArgs руками) и платформенную цепь не покрывал: класс M-F («фейк мимо шва») в центре моего же §3.1, `fakeEngine.report={RebillUnits:3}` — проекция, которой настоящий движок в этом окне не отдаёт. Рушится ВСЁ на смете: материализация · сравнение живой/мат · продажа «затронуто N юнитов» · `rebillConsent`. ⚠ Предпосылка D39.165 §3 «смета уже публикуется в status --json» верна только ПОСЛЕ свёртки — для двери она недостижима без нового движкового глагола/флага (свёртка вне translate) | `bank.go:308-320` vs `backend/internal/pipeline/status.go:634-660`; живое исполнение 3 линз независимо |
| K2 | **$0-цена мурует дверь**: `rebill_usd` у движка `float64,omitempty` — честные «units>0 по $0.00» приходят как units>0 БЕЗ цены; мой отказ «no price» → вечный `ErrBankIncomplete`, ре-сенд не сходится. Все $0/локальные деплои | `bank.go:316-320` vs `status.go:208-209`; 4 линзы |
| K3 | **Полоса-глава пере-прохода МЕРТВА**: движок анонсирует юнит ОДИН РАЗ НА ЖИЗНЬ КНИГИ (announce-once ledger, ключ без снапшота) — пере-проход не ре-анонсирует ни репины, ни пере-переводы, `unit_resolutions.at` не двигается, done=0 навсегда (и `runs.draft_done`-канал тоже молчит). ⚠ Предпосылка глава-формы («каждый визит ре-резолвит юниты») опровергнута первоисточником — решение оркестратора требует пересмотра с этой уликой | `readmodel.go:481` vs `backend/internal/store/outbox.go:58-74`, `events.go:331-336`; 2 линзы |
| K4 | Пере-проход не переживает прерываний И запечатывает дверь: reopen budget=`Pricing.Ceiling(0)`=0 → `ceilingSpent` → resume 409 `ceiling_reached`, а факт погашен `finished_at` мёртвого прогона → RePass «nothing to re-pass». Ребут → `paused/credit_exhausted` (лживое слово) через ветку «Unreachable today» | `reconcile.go:1094-1102`; исполнено тестом агента (PASS) |
| K5 | «Холд строго положителен по построению» — ЛОЖЬ: гейт RePass судит МАТЕРИАЛИЗОВАННЫЕ units, холд — ЖИВОЙ consent; живой 0 при мат>0 → «hold must be positive» → **500 internal_error** (исполнено) | `runs.go:313` vs `bank.go:345-348` |
| K6 | Факт гасится ЛЮБЫМ `finished_at` — включая failed-прогон, умерший на гарде с $0: **вечная петля** Start-без-флагов→гард→failed→… (исполнено агентом). Формулировка отчёта «успешный финиш гасит» не соответствовала коду | `books.go:1008-1012` |
| K7 | Консент не фондирован на обычной покупке: `--accept-rebill=5.01` при `--ceiling-usd 0.03` — прогон обязан сжечь бюджет на ре-билл и встать на потолке; и частичный ре-пин при этом гасит факт | `runs.go:295-306` vs `rebill.go:292-301` |
| K8 | Правка в окне `awaiting_bank`: факт невидим для resume того же прогона (AND not-exists-live) и потом гасится его же finished_at — окно, которое P9 открывал, P10 не обслуживает | `books.go:1008-1011` |
| K9 | `rebillConsent` в Resume читает ТЕКУЩИЙ `Cfg.EngineBinary`, не пиннутый `l.EngineBinary` — против дисциплины строки 139 своей же зоны | `bank.go:340` vs `spawn.go:245-254` |
| K10 | Ошибка движкового status в Start/Resume валит допуск ЦЕЛИКОМ словом 500; status (`projectRebill``withText`: полный ре-чанк+хеш-рендер книги) стоит ДО bounds-проверки и под мьютексом — «status — ремонт, не поллинг» нарушен трижды | `runs.go:300-306`, `reconcile.go:1119-1131` |
| K11 | Мой «капнутый консент» — бланкет в кепке: derived FROM the projection he bounds; мат. смета сама включает ДО-правочный деплой-дрейф → ⛔-различение работает только на дрейф ПОСЛЕ правки | `bank.go:355` vs `rebill.go:292-302` |
| K12 | Россыпь: два часовых источника предиката (now() БД vs s.now()) · `chapter_count` мутабелен в total (против канона «total = покупка») · комментарий «flagship = resume паузы» называет случай, который код отвергает (`paused``ceiling_reached`) · D39.165-половина «показывает N юнитов» не доставлена (Options.RebillUnits без провода — и без сметы недоставима) · миграционный in-flight без консентов · live-тест не гоняет `--accept-rebill` живьём (acceptRebill=0 во всех трёх вызовах — имя теста шире правды) | таблица находок, журнал wf |
### Диспозиция (МОЁ предложение; исполняю ПОСЛЕ слова оркестратора)
**Чинить своё пересбором на форму БЕЗ сметы (закрывает K1,K2,K5,K6,K7,K8,K9,K10,K11 разом):**
1. Факт «банк двигался» — от СВОЕЙ квитанции, не от движка: взвод при apply с `changed==true` ЛИБО
хоть одним `already_applied` (ретрай-сходимость держится состояниями квитанции, status не нужен);
`recordBankMove`/`rebillConsent`/`ErrRebillOutgrown`/сметные колонки — УПРАЗДНИТЬ (миграция 00027
пересобирается: только `bank_moved_at` + консенты строки).
2. Гашение факта — ЯВНОЕ, не временнОе: `finish` при `l.Resnapshot && outcome==ready`сброс
(K6-петля умирает; K8-окно работает — «лишний» `--resnapshot` при недвинутом снапшоте безвреден
по построению гарда: читается только при несовпадении снапшота; часовой dispute умирает вместе с
предикатом времени).
3. Консент — ФОНДИРОВАННЫЙ: `--accept-rebill=<холд ЭТОГО прогона>` (Pricing.Ceiling(C)) — «согласен
пере-платить не больше, чем этот прогон вообще может потратить»; удовлетворяет ⛔-«либо согласие
явным» деньгами, которые пользователь уже дал; K7 умирает (кап=бюджет), K11 сужается до честной
оговорки.
4. K4: resume пере-прохода закрыть честным словом (`ErrNotResumable`: «пере-проход не резюмится —
купи заново»; факт при нефинальном исходе стоит по п.2 → пере-покупка доступна).
**СТОП — решения оркестратора (не чиню):**
5. **§3.2-продажа** «затронуто N юнитов + холд от сметы» в текущем шве НЕДОСТИЖИМА (K1): либо
движковый глагол/флаг «свернуть и оценить» (пинг бэкенду, их пак жив), либо продажа вслепую с
иным холдом, либо §3.2 откладывается. D39.165-предпосылка требует эрраты.
6. **Полоса пере-прохода** (K3): глава-форма мертва первоисточником; варианты — движок ре-анонсирует
при ре-резолве (глагольная половина) / полоса «одна работа» (0→1 на финише) / без полосы. Твоё
слово.
7. Канонные хвосты §3.4 — как решишь по 5-6.
Дерево не менялось после сдачи; жду слова.
Дерево передаётся на лендинг (правки P10 поверх заленженного P9). Вопрос формы §3.2 решён
оркестратором в ходе пака (глава-полоса, `re_pass`-форма запроса — его слово в канале).
### Таблица комплектности против §3 (пункт → сделано → каким ИСПОЛНЕНИЕМ подтверждено)
| §3 | Что сделано | Исполнение |
|---|---|---|
| §3.1 мина | Носитель факта: миграция `00027` (`books.bank_moved_at` + материализованная смета `rebill_units`/`rebill_usd_micro`; `runs.resnapshot`/`accept_rebill_micro`); предикат факта — `bank_moved_at > max(finished_at)` книги, ОДНО сравнение закрывает все три края промта (превью/no-op не пишут — запись от ПРОЕКЦИИ, не от `changed`, и потому ретрай после провала записи сходится; правка в окне `awaiting_bank` даёт проекцию 0 — её волна без джобов — и факта не оставляет; успешный финиш нового прогона гасит факт сам). Дверь правок после каждого apply снимает `tmctl status` под тем же `lockBook` и пишет факт+смету (`recordBankMove`); провал записи → `bank_corrections_incomplete` (ре-сенд сходится байтовым no-op). Start/Resume решают ОБА флага один раз под мьютексом → строка прогона → `TranslateArgs` рендерит `--resnapshot` и ВСЕГДА КАПНУТЫЙ `--accept-rebill=<материализованная сумма + цент float-запаса>` (никогда bare-форму); реконсилерские рестарты флаги не пере-выводят (argv стабилен — дисциплина `verify_bank`). **Различение банкового сдвига от деплойного (⛔-пункт)** — сравнением ЖИВОЙ проекции с материализованной: материализованная снята В МОМЕНТ правки (цена банкового сдвига до всякого дрейфа), рост сверх неё+цент → `ErrRebillOutgrown` → 409 ДО холда; движковый гейт «потолок ниже проекции — отказ» остаётся вторым рубежом | **ЖИВОЕ РЕПРО на настоящем движке** `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem` (runner, гейтед): полный прайм-прогон обеих волн на $0-заглушке (порт local-провайдера, in-test) → живой `bank-apply` двигает банк → **без флага гард стреляет громко и НАЗЫВАЕТ `--resnapshot`** (состояние эрраты 28.08-и ДОКАЗАНО этим же отказом — зелёный здесь = вырожденная фикстура = Fatal) → с флагом проходит ($0-репин + пере-перевод затронутых). Пины: `TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot` · `TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn` (argv из спавна: `--accept-rebill=0.130000`) · `TestAResumeOverAMovedBankGrantsTheConsents` · `TestAGrownProjectionRefusesBeforeTheHold` (runs не вырос, холд не взят) · `TestTheConsentFlagsRenderExactlyWhenGranted` · `TestTheRebillProjectionCrossesTheSeam` (декод сырого JSON). Посадки M-A..M-F — 6/6 пойманы |
| §3.2 дверь | `StartRequest.RePass`: на книге с фактом и `RebillUnits>0` допускается прогон с `ceiling_chapters=0`; **холд = согласованная смета + цент** (центы, не главы×$0.03; строго положителен по построению — `CeilingTemplate` не откажет, `bookCap` остаётся положительным приращением); без факта — `ErrRePassUnavailable` (временно 409 `bounds_moved`; свой cause — в §3.4). **Полоса пере-прохода — ГЛАВА-форма по слову оркестратора**: `done` = distinct-главы, которых пере-проход коснулся (`unit_resolutions.at >= r.started_at` — движковые event-времена), `total` = `chapter_count`, `stage='re_pass'` (открытый словарь D39.163); инвариант «0/0 недостижим» держится глава-формой + пол `greatest(chapter_count,1)`; `C=0` без консента по-прежнему отвергается | `TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne` (обе стороны; холд 130000 в леджере, C=0 в строке) · `TestARePassRunsBarWalksTheBook` (0/2 `re_pass` на старте → 1/2 после касания главы; C=0 без консента отвергнут) |
| §3.3 смета | `rebill_units`+`rebill_usd` в аллоулисте `StatusReport` (ОБА, по поправке промта; доллары → micro-USD НА ШВЕ как `Spend`, указатель — absent ≠ бесплатно; units>0 без цены = отказ шва, не нулевое согласие); `runs.Options.RebillUnits` — юниты для показа до покупки (провод — контрактная половина); наружу доллары НЕ выходят | декод-пин `TestTheRebillProjectionCrossesTheSeam`; опровергатель на пропуск СПАВНЕН с мандатом промта (§4.5) — вердикт аддендумом ниже |
| §3.4 контракт | ОПИСАНО предложением ниже, канон не тронут | секция «Предложение §3.4» ниже |
### Числа (каждое — командой)
- Батарея с гейтами: `go test ./... -race -count=1 -v` → **EXIT=0, 18 пакетов ok,
`grep -c -- '--- SKIP'` → 0, `grep -c '^=== RUN'` → 802**, FAIL/DATA RACE — 0. ⚠ Финальный прогон
после последней правки (Options.RebillUnits) — дописка ниже.
- **Скипы БЕЗ DSN (⚠-требование §4.1, командой):** `env -u TM_PLATFORM_TEST_DSN … go test ./...`
→ EXIT=0, `grep -c -- '--- SKIP'`**286** (из 783 RUN) — «зелень без DSN» не проверяет треть
зоны запусками и ещё часть скипами в TestMain; ловушка D39.162 воспроизведена числом.
- Линтер `golangci-lint run`**0 issues** (4 находки — noctx×2/staticcheck/gofmt в новом
live-тесте — починены); `gofmt -l` пусто.
- Посадки: **M-A** argv теряет `--resnapshot``TestTheConsentFlags` · **M-B** дверь не пишет факт
`TestACorrectionRecords` · **M-C** сравнение смет снято → `TestAGrownProjection` · **M-D**
RePass без факта → `TestTheRePassDoor` · **M-E** глава-ветка полосы снята → `TestARePassRunsBar`
(0/0 пойман) · **M-F** json-тег сметы сломан → `TestTheRebillProjectionCrossesTheSeam`.
**6/6 пойманы топично** (копия `~/tm-p9-mut2`, база зелёная). ⚠ Урок M-F честно: ПЕРВАЯ посадка
прошла все сервис-тесты — `fakeEngine` отдаёт Go-структуру МИМО json-тегов; ловец — только пин на
декоде сырого JSON. Класс «фейк ходит мимо шва» — знать при ревью любых шов-полей.
### Предложение §3.4 (контрактная половина — правит оркестратор)
1. **`RunOptions`**: поле `re_pass_units` (int, ≥0; 0 = пере-прохода нет) — «затронуто N юнитов»;
имя с `re_pass`, не «rebill» (движковое слово на проводе не живёт). Внутренний носитель готов
(`runs.Options.RebillUnits`).
2. **`POST /runs`**: булев член `re_pass` (по твоей ноте-решению; без `ceiling_chapters`), легален
только при `re_pass_units > 0` в опциях; ответ — обычный Run с полосой глава-формы.
3. **`Progress`**: оговорка к `total` («what this run bought» → для пере-прохода «работа, которую
прогон обходит» — твоя формулировка в канале); `stage` получает значение `re_pass` в примерах
открытого словаря.
4. **`openapi.yaml:590`** «finished work is not bought twice» → оговорка: пере-проход покупает НЕ
работу заново, а доставку правки в уже купленное; цена — только затронутые юниты, незатронутое
$0 (D39.165 §3).
5. **§2.12 компаньона**: «ре-билл» остаётся запретным СЛОВОМ провода, но исключение для СЧЁТА
работы: `re_pass_units` — счёт той же природы, что `total_units`; доллары проекции запретными
остаются (D39.84).
6. **Cause-коды** взамен временного `bounds_moved`: `re_pass_outgrown` (живая проекция выросла сверх
показанной — пере-читай опции) и `re_pass_unavailable` (пере-прохода нет — банк не двигался или
не тронул оплаченного). Оба 409 на `startRun`.
### Аддендум: вердикт опровергателя §3.3 (заказ §4.5) и финальная батарея
Опровергатель (мандат промта: «покажи, где пропуск нарушает §2.12 или D39.84») вернулся с
разбором по трём основаниям и полной трассой значений:
- **§2.12 — НЕ нарушает**, и довод сильнее моего: «пять денежных полей» §2.12 — поимённо
`RebillUSD`/`CommittedUSD`/`ReservedUSD`/`BookCeilingUSD`/`ProjectedBookUSD`, и ДВА из пяти
(`Spend`, `Reserved`) уже ЛЕГАЛЬНО пересекают шов в заленженном аллоулисте — чтение «§2.12
запрещает шов» делало бы их нарушениями задним числом; `rebill_usd` — третье из пяти. D39.165 §3
допускает оба поля поимённо и пофайльно. Правка §2.12 остаётся ОБЯЗАТЕЛЬНОЙ (безусловная
формулировка учит обратному) — уже в предложении §3.4. ⚠ Плюс его находка ДЛЯ ОРКЕСТРАТОРА:
якоря §2.12 (`pipeline/status.go:58-130`, `:37-55`) ПРОТУХЛИ — волновая машинерия D39.122
сдвинула структуры (деньги теперь в `ChapterPassport`:153-175 и `StatusReport`:178-269).
- **D39.84 — не нарушает** (запрет — поля в UI; сам D39.84 называет «rebill-согласие движка» как
нетронутую легитимную механику; доля-не-сумма проверена на `wireUsage`).
- **Шапка `resync.go:10-13` НАРУШАЛА — моя вина, поймано им, починено этим же деревом:** список
«absent on purpose» всё ещё называл `rebill` и формулировал правило как «must not cross the
seam» — два чтения в одном док-комментарии. Вычеркнуто, различение шов/провод внесено в шапку
с именем находки.
- **Путей утечки на провод/в лог НЕ найдено** — полная трасса обоих значений (БД → строка прогона
→ argv [не логируется по PD-99] → движок; синк ре-синка ре-билл-поля НЕ материализует; options и
projectRun денег не несут; INFO-строки несут только id и счёт глав; метрики/SSE чисты).
- Попутные его факты: (а) `re_pass` в `stage` легален (открытый словарь), но канону значение
записать — уже п.3 предложения §3.4; (б) проводного носителя `re_pass` у двери нет — ВЕРНО, это
и есть контрактная половина (п.2 предложения): платформенная половина пака сознательно
недостижима с провода до канонного члена.
**Финальная батарея** (после ВСЕХ правок, включая Options.RebillUnits и шапку resync):
`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, RUN=802**,
FAIL/DATA RACE — 0; линтер **0 issues**; `gofmt -l` пусто.
### Obstacle — что НЕ удалось и что НЕ проверено
- **Консент-гейт движка живьём НЕ пробит деньгами**: на $0-ценах local-пары проекция всегда $0 и
под порогом — живой отказ «over the consent threshold» требует ненулевого прайса (живого ключа
или прайс-таблицы с ценой). Консент-половина доказана юнитами (argv, сумма, отказ роста) и
движковым контрактом (`--accept-rebill=<usd>` ниже проекции — отказ; текст `rebill.go:322`
сверен), НЕ живым прогоном. Требует стенда с ненулевым прайсом — кандидат строки 202.
- **`ur.at >= r.started_at`** сравнивает движковое event-время с платформенным `now()` одного
хоста; на разъехавшихся часах мульти-хостового будущего полоса пере-прохода может недосчитать
главы (транзиентно, до следующего касания). Названо в комментарии `rePassDone`.
- **Идемпотентный ключ повторного `POST /runs {re_pass}`** — не строился (как и у обычного Start
вне идемпотентности ключа запроса); повтор после успеха отвечает `run_in_flight`/`ErrRePassUnavailable`
(факт погашен финишем) — вырожденных дублей не нашёл, но специального пина нет.
- Опровергатель §3.3 — спавнен, вердикт аддендумом (на момент записи ещё бежал).
## ПАК P10 — ЗАПИСКА-ПЛАН (сессия платформы, 28.08, ДО правок; промт `docs/PLATFORM_P10_SESSION_PROMPT.md`, D39.165)
Карта чтения промта пройдена целиком; все движковые факты промта пере-проверены чтением
(`stagerun.go:52-58` гард и `:88-107` $0-репин · `rebill.go:322` плоский отказ и `:38` порог ·
`status.go:202-209` смета · `main.go:252-262` ортогональность и кумулятивность капа · `book.go`
промпты/langpack в снапшоте) — расхождений с промтом НЕТ.
### Выбранные формы (обоснование — рядом; посадочная проверка в §4 промта)
1. **Носитель факта «банк двигался» — колонки на `books`**: `bank_moved_at timestamptz` (момент
успешного apply с `changed:true`; превью и no-op НЕ пишут) + материализованная смета
`rebill_units int` / `rebill_usd_micro bigint` (снятая `tmctl status` в самой двери правок сразу
после apply, под тем же `lockBook`, $0). **Предикат факта: `bank_moved_at > finished_at
ПОСЛЕДНЕГО прогона** (или прогон отсутствует ⇒ факта нет — все джобы будут свежими). Эта
семантика закрывает все три края промта БЕЗ спец-случаев: правка в окне `awaiting_bank` легла
ДО финиша того же прогона ⇒ факт не встаёт ⇒ resume без флагов, гард молчит (edit-джобов нет —
эррата 28.08-и); правка после паузы потолком посреди редактуры ⇒ факт стоит ⇒ resume несёт
флаги; правка на дочитанной ⇒ факт стоит ⇒ дверь §3.2. Успешный финиш пере-прохода гасит факт
сам (новый `finished_at` > `bank_moved_at`), без отдельного сброса.
2. **Стабильность argv при респавне — по образцу `verify_bank`**: решение о флагах принимается ОДИН
раз в Start/Resume под `lockBook` и пишется в строку прогона (`runs.resnapshot bool`,
`runs.accept_rebill_micro bigint`); `spawn.spec` читает строку. Флаг не может появиться посреди
прогона по построению.
3. **Различение банкового сдвига от деплойного — сравнением СМЕТ, не чтением снапшотов** (снапшоты
— словарь движка и через шов не ходят). Смета материализуется В МОМЕНТ правки банка — это
чистая цена БАНКОВОГО сдвига. На Start/Resume под мьютексом смета пере-снимается живьём ($0);
если живая проекция ВЫШЕ материализованной сверх допуска — сдвиг не (только) банковый
(деплой двинул промпты/langpack) ⇒ **явный отказ 409 ДО холда** с ремеди «пере-смотри смету»
(обновить материализованную = пере-показать пользователю). Согласие движку — ВСЕГДА
`--accept-rebill=<материализованная сумма>`: сумма, которую видел пользователь; движковый гейт
«потолок ниже проекции — отказ» остаётся вторым рубежом. «Свежая квитанция» сама по себе
основанием не является — основание всегда КОНКРЕТНАЯ согласованная сумма (D20.2-Q2).
4. **Дверь §3.2**: допуск в `Start` — на книге с фактом шкала не отбивает старт; **холд =
строго положительное приращение от СМЕТЫ** (`max(rebill_usd, пол в 1 цент)` — не главы×$0.03;
пере-проход стоит центы, незатронутое $0), `bookCap = committed + этот холд` (кумулятивная
семантика не трогается).
5. **Шов §3.3**: `rebill_units`+`rebill_usd` в аллоулист `StatusReport` (оба, по поправке промта;
доллары в micro-USD на шве, как `Spend`; наружу в API — только юниты). Опровергатель на пропуск
— заказ §4.5, спавню с мандатом «покажи, где это нарушает §2.12 или D39.84».
### Открытые оси (форма решится при исполнении; затык = пинг, не интерпретация)
- **Как клиент ПРОСИТ пере-проход и что показывает полоса такого прогона.** Дочитанная книга:
`Scale.Max=0`, валидного `ceiling_chapters` у клиента нет. Черновая форма: options объявляет
`rebill_units>0`, Start принимает пере-проходную форму запроса (внутренне `ceiling_chapters=0`
легализуется ТОЛЬКО при факте) — но `runTotal=0` полосе запрещён, полоса пере-прохода — из
юнитов сметы. Это контрактная половина — опишу предложением §3.4, до слова оркестратора провод
не трогаю. «Вид покупки» на провод НЕ несу (D39.163: «третьего исключения нет»); если форма
без него не встанет — довод по существу отдельно.
- Допуск против «частично дочитанной» книги (остаток >0 И банк двигался): обычная покупка уже
доступна — несёт ли она флаги? Да (мина бьёт по продолжению с edit-джобами) — это §3.1, не §3.2.
### Порядок работ (§3.1 первым, как заказано)
1. Миграция (3 колонки books + 2 колонки runs) → 2. запись факта+сметы в двери правок → 3. флаги
в Start/Resume + `TranslateArgs` → 4. репро мины на ЖИВОМ tmctl (edit-джоб ДО правки — состояние
эрраты; прецедент `bankapply_live_test.go`) → 5. дверь допуска + холд от сметы → 6. аллоулист шва +
опровергатель → 7. options/предложения §3.4 в журнал → 8. посадки, батарея (скипы с DSN и без —
командой), оси ревью 13.
### Не-делать (из промта, себе)
`backend/` и `docs/` не трогаю · $0.03 не трогаю · цикл пост-ридинга гейчен (§0-бис) · чужие 20
позиций полигона · `docs/PROGRESS.md` не пишу.
## ФИКС-РАУНД ПО ДИСПОЗИЦИЯМ ОРКЕСТРАТОРА ИСПОЛНЕН — 13 фиксов, 9/9 посадок пойманы, одна находка воркфлоу ОПРОВЕРГНУТА исполнением, канон 0.6.0 принят (сессия платформы, 28.08, после записи ниже)
Диспозиции пришли двумя сообщениями оркестратора (28.08) + третьим — канонная половина полосы
@ -522,7 +820,7 @@ mutation-catch (спутать пары «числитель×база»); ко
⚠ Пере-именование банк-экспорта в фикс-имя — ЛОМАЮЩЕЕ, ему место в окне строки 161, не здесь.
4. **Поле, которое декодируется и не читается.** `ingest.StatusReport.UnsignedBankTerms`
(`internal/ingest/resync.go:30`=`UnsignedBankTerms`) разбирается из ответа движка и не используется
(`internal/ingest/resync.go:36`=`UnsignedBankTerms`) разбирается из ответа движка и не используется
НИ ОДНОЙ строкой продакшн-кода зоны. Либо потребитель потерян при спиле пер-термного пути, либо поле
лишнее — решать вам; я называю факт, потому что мёртвое поле в структуре шва читается как контракт.

View file

@ -10,7 +10,7 @@ import "net/http"
// client generated against another one refuses to work and says so — which is why this must be
// raised in the same commit as the code that implements a new minor, and never as a courtesy
// afterwards.
const ContractVersion = "0.6.0"
const ContractVersion = "0.7.0"
// Capabilities is what this deployment can do: one flat document, the same for every account.
type Capabilities struct {

View file

@ -61,6 +61,10 @@ const (
CauseCreditUnavailable = "credit_unavailable"
// CauseBoundsMoved — the scale moved between the read and the call.
CauseBoundsMoved = "bounds_moved"
// CauseRePassUnavailable — a re-pass was asked for and there is nothing to re-pass: no
// correction was recorded since the last run, or a resnapshot run has already walked it in
// (canon §RunRequest.re_pass, 0.7.0).
CauseRePassUnavailable = "re_pass_unavailable"
// CauseCreditHeld — another book of this account holds the credit; `blocked` names it.
CauseCreditHeld = "credit_held"
// CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`.

View file

@ -211,11 +211,13 @@ type wireRunOptions struct {
}
type wireRunRequest struct {
// Pointers because both fields are REQUIRED and "absent" has to be told from "false" and from
// "zero": a run started without a declared ceiling would spend past the limit the user is
// entitled to set beforehand (canon §RunRequest).
// Pointers because "absent" has to be told from "false" and from "zero": a run started without
// a declared ceiling would spend past the limit the user is entitled to set beforehand (canon
// §RunRequest). Since 0.7.0 `ceiling_chapters` is required for an ORDINARY run only: `re_pass`
// buys no chapters and the two members are mutually exclusive — both at once is 400.
StopForSigning *bool `json:"stop_for_signing"`
CeilingChapters *int `json:"ceiling_chapters"`
RePass *bool `json:"re_pass"`
}
type wireUsage struct {
@ -316,22 +318,30 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) {
Invalid(w, r)
return
}
rePass := req.RePass != nil && *req.RePass
var missing []Item
if req.StopForSigning == nil {
missing = append(missing, Item{Pointer: "/stop_for_signing", Code: ItemMissing})
}
if req.CeilingChapters == nil {
if req.CeilingChapters == nil && !rePass {
missing = append(missing, Item{Pointer: "/ceiling_chapters", Code: ItemMissing})
}
if len(missing) > 0 {
Invalid(w, r, missing...)
return
}
// The two purchases are mutually exclusive (canon §RunRequest, 0.7.0): a re-pass buys no
// chapters, so a chapter limit beside it would describe nothing — and guessing which of the
// two the caller meant is exactly the quiet half-belief this surface refuses elsewhere.
if rePass && req.CeilingChapters != nil {
Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemMalformed})
return
}
// The schema's own minimum (RunRequest.ceiling_chapters, minimum: 1) belongs HERE and not in the
// service: a request that violates the schema is malformed, and answering it with 409 would tell
// the client the bounds had moved — so it would re-read run-options and retry, forever, a request
// that can never succeed.
if *req.CeilingChapters < 1 {
if !rePass && *req.CeilingChapters < 1 {
Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemOutOfRange})
return
}
@ -342,12 +352,16 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) {
return
}
defer key.release(r.Context()) // every exit settles the key; `complete` below cancels it
run, err := h.runs.Start(r.Context(), runs.StartRequest{
UserID: user,
BookID: r.PathValue("bookId"),
VerifyBank: *req.StopForSigning,
CeilingChapters: *req.CeilingChapters,
})
in := runs.StartRequest{
UserID: user,
BookID: r.PathValue("bookId"),
VerifyBank: *req.StopForSigning,
RePass: rePass,
}
if !rePass {
in.CeilingChapters = *req.CeilingChapters
}
run, err := h.runs.Start(r.Context(), in)
if err != nil {
h.fail(w, r, err)
return
@ -797,6 +811,10 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) {
// 409 and not 400: the request was legal when the bounds were read, and a hold taken for
// another book between that read and this call is what moved them (canon §startRun).
FailCause(w, r, CodeCeilingUnavailable, CauseBoundsMoved)
case errors.Is(err, runs.ErrRePassUnavailable):
// Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a
// resnapshot run already walked it in (canon §RunRequest.re_pass).
FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable)
case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete):
// A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145)
// or no way to record how a unit ended.

View file

@ -704,3 +704,30 @@ func TestOnlyTheContractsOwnPausedReasonReachesTheWire(t *testing.T) {
}
func ptr[T any](v T) *T { return &v }
// The 0.7.0 re-pass purchase on the wire (canon §RunRequest): `re_pass` without a chapter limit
// reaches the service as the re-pass; the two members together are 400 (mutually exclusive); and
// «nothing to re-pass» answers 409 with its own cause word.
func TestARePassRequestIsItsOwnPurchaseShape(t *testing.T) {
f := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating"}}
h := v0Server(t, &fakeLibrary{}, f)
if w := call(t, h, "POST", "/v0/books/bk_1/runs",
`{"stop_for_signing":false,"re_pass":true}`); w.Code != http.StatusAccepted {
t.Fatalf("a re-pass request answered %d: %s", w.Code, w.Body)
}
if !f.got.RePass || f.got.CeilingChapters != 0 {
t.Fatalf("the service saw %+v, want RePass with no chapters", f.got)
}
if w := call(t, h, "POST", "/v0/books/bk_1/runs",
`{"stop_for_signing":false,"re_pass":true,"ceiling_chapters":3}`); w.Code != http.StatusBadRequest {
t.Fatalf("both purchases at once answered %d, want 400", w.Code)
}
f.err = runs.ErrRePassUnavailable
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"re_pass":true}`)
if w.Code != http.StatusConflict {
t.Fatalf("nothing-to-re-pass answered %d, want 409", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, `"re_pass_unavailable"`) {
t.Fatalf("the 409 does not carry its own cause: %s", body)
}
}

View file

@ -8,9 +8,15 @@ import (
)
// StatusReport is the ALLOWLISTED subset of `tmctl status --json` (pipeline.StatusReport) that the
// platform materializes. Everything absent here is absent on purpose: snapshot ids, drift, rebill,
// routing, content labels and the operator's flag taxonomy are engine vocabulary that must not
// cross the seam (contract §2.12), and unknown JSON fields are simply ignored by encoding/json.
// platform materializes. Everything absent here is absent on purpose: snapshot ids, drift itself,
// routing, content labels and the operator's flag taxonomy are engine vocabulary with no consumer
// on this side. The rule the list enforces is about the WIRE, not the seam: §2.12 forbids these
// words from reaching a client, and D39.84 forbids projecting money into an API response or an
// INFO log — crossing the seam into the credit and consent machinery is legal and precedented
// (Spend and Reserved below; the re-bill pair, D39.165 §3). An earlier edition of this header read
// the rule as "must not cross the seam", which its own field comments had already corrected — the
// refuter of P10 caught the two readings standing side by side. Unknown JSON fields are simply
// ignored by encoding/json.
type StatusReport struct {
BookID string `json:"book_id"`
TotalUnits int `json:"total_units"`
@ -28,6 +34,13 @@ type StatusReport struct {
ETASeconds float64 `json:"eta_seconds"`
// UnsignedBankTerms backs the signing screen's "N of M decided" while a stop is standing.
UnsignedBankTerms int `json:"unsigned_bank_terms"`
// The engine's re-bill projection (rebill_units/rebill_usd) is deliberately NOT taken, and the
// reason is not vocabulary but TIMING: status projects the STORED memory, and a correction
// reaches it only when the next translate folds the bank in — so right after an apply, the one
// moment a consent would want the figure, it honestly reads zero (P10 errata 28.08-к). The
// consents are funded from the run's own hold instead, and a field without a consumer is the
// defect class this project names — the pair returns with the engine verb that can fold and
// price a correction OUTSIDE a run (orchestrator's backlog row).
// Spend is the engine's committed spend, converted to integer micro-USD AT THE SEAM. The wire
// value is a JSON decimal; binding it to a float64 would put drift one step before the integer
// column that exists to prevent drift (PD-15). It is stored in the credit tables and NEVER

View file

@ -971,6 +971,9 @@ type BookRunContext struct {
// from intake and nothing per chapter, so a book with no materialized chapters answers with its
// whole length — which is right for a book that has never run.
ChaptersLeft int
// ChapterCount is the book's whole length — what a re-pass hold prices, since a re-pass may in
// the worst case re-translate everything the correction touched, up to the book.
ChapterCount int
HasLiveRun bool
// LiveRunAwaitingBank narrows HasLiveRun for the correction door: a bank stop moves the run's
// status to `awaiting_bank` from the journal BEFORE the reconciler reads the exit marker and
@ -979,6 +982,15 @@ type BookRunContext struct {
// answer run_in_flight; Start keeps refusing on HasLiveRun alone (the row IS still live, and a
// second one would break runs_one_live_per_book).
LiveRunAwaitingBank bool
// BankMoved is the re-pass door's fact (P10, errata 28.08-к): a correction landed that the
// already-translated text does not carry, and no --resnapshot run has finished `ready` since.
// A plain standing flag, deliberately NOT a timestamp comparison: the door stamps it from its
// own receipt and reconcile.finish clears it explicitly on a ready resnapshot run, so there is
// no clock pairing to skew and no failed run to retire it early (the adversarial pass's K6 and
// its clock dispute both die with the predicate). The asymmetry is chosen: a STALE standing
// fact costs one harmless --resnapshot (the engine reads the flag only when a snapshot actually
// moved), a falsely-retired one kills the next run on the guard after its hold.
BankMoved bool
}
// ReadBookForRun gathers the facts a run start is judged on.
@ -988,13 +1000,15 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book
b.chapter_count - (select count(*) from chapters c
where c.book_id = b.id and c.units_total > 0
and ` + finishedUnits + ` >= c.units_total),
b.chapter_count,
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null),
exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null
and lr.status = 'awaiting_bank')
and lr.status = 'awaiting_bank'),
b.bank_moved_at is not null
from books b ` + lastRun + ` where b.id = $1 and b.owner_id = $2`
var out BookRunContext
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft,
&out.HasLiveRun, &out.LiveRunAwaitingBank)
&out.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved)
if errors.Is(err, pgx.ErrNoRows) {
return BookRunContext{}, ErrNoBook
}
@ -1007,6 +1021,34 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book
return out, nil
}
// RecordBankMove is the correction door's write: the bank moved. Stamped from the door's OWN
// receipt — never from the engine's status projection, which is blind to a correction until the
// next translate folds the bank into memory (P10 errata 28.08-к, the adversarial pass's K1). A
// plain flag: what re-passing would COST is unknowable at this moment by construction, so nothing
// pretends to price it.
func (s *Store) RecordBankMove(ctx context.Context, bookID string) error {
tag, err := s.pool.Exec(ctx, `update books set bank_moved_at = now() where id = $1`, bookID)
if err != nil {
return fmt.Errorf("pgstore: record bank move: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNoBook
}
return nil
}
// ClearBankMove retires the fact — called by the reconciler when a run that CARRIED --resnapshot
// finished `ready`: the correction has demonstrably reached the translated text. Deliberately not
// keyed on time and not called for failed/stopped/paused endings: a partial re-pin leaves
// superseded units behind, and the next admission must still carry the flags.
func (s *Store) ClearBankMove(ctx context.Context, bookID string) error {
if _, err := s.pool.Exec(ctx,
`update books set bank_moved_at = null where id = $1`, bookID); err != nil {
return fmt.Errorf("pgstore: clear bank move: %w", err)
}
return nil
}
// Usage is the credit state, in the only form that crosses the boundary: a share, never a sum
// (D39.84, contract §Usage).
type Usage struct {

View file

@ -42,3 +42,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod
0213ea7636b536d1aad46b617ee399cccbf8efd87b3eb282340cdc9430a96b0e 00024_book_edit_wave.sql
1ad95e77c78d556a83e35c2fceac09475240c7a919fd3554e34cb5bf62ef52d1 00025_stalled_work.sql
14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql
6b0f226e2b5acb90e930337f6548ba777f985deb4472a9f900709acb504ab99f 00027_bank_move.sql

View file

@ -0,0 +1,27 @@
-- +goose Up
-- The carrier of "the bank moved" — the fact the correction door creates and nothing recorded
-- until now (the receipt died with the caller; P10, D39.165 §3 + errata 28.08-к). The door stamps
-- it from its OWN receipt (an apply that changed files, or answered already_applied on a retry) —
-- never from the engine's status projection, which is BLIND to a correction until the next
-- translate folds the bank into memory (the adversarial pass's K1). The fact is retired
-- EXPLICITLY: a run that carried --resnapshot and finished `ready` clears it in reconcile.finish —
-- no timestamp comparison, no clock pairing, and a failed or interrupted run leaves the fact
-- standing, so the next admission still carries the flags instead of dying on the engine's
-- snapshot guard forever.
alter table books add column bank_moved_at timestamptz;
-- The run's own consents, decided ONCE at admission (Start/Resume, under the book lock) and read
-- by every spawn of every attempt — the discipline verify_bank rides: argv must be STABLE across
-- respawns, a flag must not appear mid-run because a sweep re-derived it from fresher state.
-- accept_rebill_micro is the FUNDED cap: the run's own hold — consent to re-pay no more than this
-- run may spend at all — never a projection (which does not exist at admission time, K1) and never
-- the bare blanket form.
alter table runs add column resnapshot boolean not null default false;
alter table runs add column accept_rebill_micro bigint not null default 0;
-- +goose Down
alter table runs drop column accept_rebill_micro;
alter table runs drop column resnapshot;
alter table books drop column bank_moved_at;

View file

@ -470,13 +470,26 @@ const (
// A pipeline with no editor has ONE wave, and its draft column IS the last pass: counting both
// halves there would count every chapter twice against a doubled total that one wave can never
// reach.
runDone = `(case when ` + editWave + ` then ` + draftBar + ` + ` + editBar + ` else ` + draftOnlyBar + ` end)`
runTotal = `(case when ` + editWave + ` then ` + draftWork + ` + r.ceiling_chapters else r.ceiling_chapters end)`
// A RE-PASS run (P10, D39.165 §3 + errata 28.08-к) buys no chapters — ceiling_chapters is 0,
// the one shape that writes it — and its bar is ONE UNIT OF WORK: 0 until the run finishes
// clean, 1 then. Declared, not smuggled (the orchestrator's canon note): no finer honest
// granularity exists — the engine announces a unit once for the life of the book and
// re-announces nothing on a re-pass (announce-once ledger; the first edition counted chapters
// by resolution times and read 0/N forever — adversarial K3). The 0/0 frame stays unreachable:
// the denominator is the literal 1.
runDone = `(case when r.ceiling_chapters = 0 then
(case when r.finished_at is not null and r.status = 'ready' then 1 else 0 end)
when ` + editWave + ` then ` + draftBar + ` + ` + editBar + ` else ` + draftOnlyBar + ` end)`
runTotal = `(case when r.ceiling_chapters = 0 then 1
when ` + editWave + ` then ` + draftWork + ` + r.ceiling_chapters else r.ceiling_chapters end)`
// runStage is the caption's machine value — what the run is doing NOW, for the client to phrase
// (open vocabulary, like the correction receipt's `depth`). Derived from the same counters as the
// bar so the two cannot disagree: the run is `editing` once the draft passes IT owed are done —
// at once, when it owed none — and always `drafting` where drafting is the only pass there is.
runStage = `(case when ` + editWave + ` and ` + draftBar + ` >= ` + draftWork + `
// (open vocabulary, like the correction receipt's `depth`; D39.163 — new values move no
// version). Derived from the same counters as the bar so the two cannot disagree: the run is
// `editing` once the draft passes IT owed are done — at once, when it owed none — always
// `drafting` where drafting is the only pass there is, and `re_pass` for the whole of a re-pass
// run, which is neither.
runStage = `(case when r.ceiling_chapters = 0 then 're_pass'
when ` + editWave + ` and ` + draftBar + ` >= ` + draftWork + `
then 'editing' else 'drafting' end)`
)

View file

@ -1308,3 +1308,41 @@ func TestAWrittenOffSurfaceCanBeAskedForAgain(t *testing.T) {
t.Error("re-arming a debt that is already owed reported that it did something")
}
}
// A re-pass run's bar is ONE UNIT OF WORK (P10 §3.2, errata 28.08-к): 0 while it runs, 1 when it
// finishes clean — declared in the canon, not smuggled into a chapter count (the engine
// re-announces nothing on a re-pass, so no finer honest granularity exists). The 0/0 frame stays
// unreachable (the denominator is the literal 1), and a zero ceiling WITHOUT the re-pass consent
// stays refused.
func TestARePassRunsBarIsOneUnitOfWork(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
t.Fatal(err)
}
at := time.Now().UTC()
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 0,
Resnapshot: true, AcceptRebill: money.MicroUSD(130_000), Ceiling: money.MicroUSD(130_000),
Now: at}, 0, nil)
if err != nil {
t.Fatal(err)
}
if run.Progress.Done != 0 || run.Progress.Total != 1 || run.Progress.Stage != "re_pass" {
t.Fatalf("a fresh re-pass opens at %d/%d %q, want 0/1 re_pass",
run.Progress.Done, run.Progress.Total, run.Progress.Stage)
}
exec(t, s, ctx, `update runs set status = 'ready', finished_at = $2 where id = $1`,
run.ID, at.Add(time.Minute))
got, err := s.ReadRun(ctx, "u1", run.ID)
if err != nil {
t.Fatal(err)
}
if got.Progress.Done != 1 || got.Progress.Total != 1 || got.Progress.Stage != "re_pass" {
t.Fatalf("a finished re-pass reads %d/%d %q, want 1/1 re_pass",
got.Progress.Done, got.Progress.Total, got.Progress.Stage)
}
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 0,
Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Hour)}, 0, nil); err == nil {
t.Fatal("a zero-chapter run without the re-pass consent was admitted")
}
}

View file

@ -38,6 +38,13 @@ type StartRunInput struct {
// impossible even while the platform is blind (D39.100).
Ceiling money.MicroUSD
Now time.Time
// Resnapshot/AcceptRebill are the re-pass consents, decided ONCE at admission and stored on the
// run the same way VerifyBank is: every spawn of every attempt reads the row, so argv is stable
// across respawns and a flag cannot appear mid-run (P10 §3.1). AcceptRebill is the CONCRETE sum
// the platform consents to (`--accept-rebill=<usd>`) — the projection the user was shown, never
// a blanket yes (D20.2-Q2).
Resnapshot bool
AcceptRebill money.MicroUSD
}
// StartedRun is what the caller needs after a successful start.
@ -64,7 +71,11 @@ type StartedRun struct {
// a second call because River owns its SQL and this package owns its own: neither reaches into the
// other, and the atomicity is still real.
func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset int64, enqueue func(context.Context, Tx, string) error) (StartedRun, error) {
if in.CeilingChapters <= 0 {
// Zero is ONE legal shape: the re-pass run (P10), which buys no chapters — it re-walks the book
// under the consents, so Resnapshot is what marks it. Everything else still needs a positive
// ceiling; the bar of a zero-ceiling row switches to the re-pass form (readmodel.runTotal), so
// the 0/0 frame stays unreachable.
if in.CeilingChapters < 0 || (in.CeilingChapters == 0 && !in.Resnapshot) {
return StartedRun{}, fmt.Errorf("pgstore: a run needs a positive chapter ceiling, got %d", in.CeilingChapters)
}
out := StartedRun{JournalOffset: journalOffset}
@ -90,8 +101,9 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in
// (runDone pairs them at READ time), so no flip can strand the bar.
const insertRun = `
insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision,
resnapshot, accept_rebill_micro,
chapters_before, draft_before)
select $1, $2, 'translating', $3, $4, $5, b.revision + 1,
select $1, $2, 'translating', $3, $4, $5, b.revision + 1, $7, $8,
(select count(*) from chapters c
where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total),
(select count(*) from chapters c
@ -99,7 +111,8 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in
from books b where b.id = $2 and b.owner_id = $6
returning id, book_id, revision, status, verify_bank, ceiling_chapters,
coalesce(paused_reason, ''), started_at, finished_at`
err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID).
err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID,
in.Resnapshot, int64(in.AcceptRebill)).
Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.CeilingChapters,
&out.PausedReason, &out.StartedAt, &out.FinishedAt)
if errors.Is(err, pgx.ErrNoRows) {
@ -198,6 +211,11 @@ type LiveRun struct {
// BankReleased is whether this run's signing stop has already been lifted. It decides whether the
// next attempt is spawned WITH the engine's `--verify-bank`: resume means the stop is over.
BankReleased bool
// Resnapshot/AcceptRebill are the run's re-pass consents (P10): read by every spawn so the argv
// is the admission's decision, never a sweep's re-derivation. AcceptRebill == 0 means no consent
// was given (the flag is not passed); a consent is always a concrete sum.
Resnapshot bool
AcceptRebill money.MicroUSD
// CeilingChapters is the run's whole budget, in the unit the user chose; Ceiling is what THIS
// attempt was allowed to spend. They differ after a restart, which gets what is left.
CeilingChapters int
@ -525,7 +543,8 @@ func (s *Store) AbandonRun(ctx context.Context, runID, reason string, giveTheHol
// runColumns is the reconciler's view of a run. Written once because the two queries that use it
// differ only in which runs they select, and a scan list copied twice is a scan list that drifts.
const runColumns = `
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.bank_released, r.ceiling_chapters,
select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.bank_released, r.resnapshot,
r.accept_rebill_micro, r.ceiling_chapters,
coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at,
a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''),
a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null,
@ -543,9 +562,10 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live
var out []LiveRun
for rows.Next() {
var l LiveRun
var ceiling, ceilingArg int64
var ceiling, ceilingArg, acceptRebill int64
var baseline *int64
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.BankReleased, &l.CeilingChapters,
if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.BankReleased, &l.Resnapshot,
&acceptRebill, &l.CeilingChapters,
&l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt,
&l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID,
&l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling,
@ -554,6 +574,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live
}
l.Ceiling = money.MicroUSD(ceiling)
l.CeilingArg = money.MicroUSD(ceilingArg)
l.AcceptRebill = money.MicroUSD(acceptRebill)
if baseline != nil {
v := money.MicroUSD(*baseline)
l.SpendBaseline = &v
@ -747,6 +768,12 @@ type RestartInput struct {
// EngineBinary overrides the pinned path. Nil means INHERIT — which is the default, because a
// resume is the same run continuing and row 139 lets another version in only on purpose.
EngineBinary *string
// Resnapshot/AcceptRebill GRANT the re-pass consents on re-open (a resume of a run the bank
// moved under — P10 §3.1). Widening only: the row keeps a consent it already carries, and the
// sum only grows (a smaller fresh projection is covered by the larger consent already given).
// The reconciler's restarts pass zero values and change nothing.
Resnapshot bool
AcceptRebill money.MicroUSD
// OnlyIfLive refuses to re-open a run that has already FINISHED. The reconciler sets it and a
// resume does not, and the difference is money.
//
@ -868,9 +895,12 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
update runs r set status = 'translating', paused_reason = null, finished_at = null,
settled_at = null, stop_requested_at = null,
bank_released = bank_released or $2,
resnapshot = r.resnapshot or $3,
accept_rebill_micro = greatest(r.accept_rebill_micro, $4),
revision = b.revision + 1
from books b
where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop); err != nil {
where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop,
in.Resnapshot, int64(in.AcceptRebill)); err != nil {
// Clearing finished_at puts the run back under the one-live-run-per-book index, and the
// book may already have a NEWER live run — nothing stops an account starting one after it
// stopped this one. That is a conflict and not a failure: the whole transaction rolls back,
@ -902,11 +932,15 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error
return LiveRun{}, err
}
// The fields the caller needs to spawn but this transaction did not read.
const q = `select b.workdir, r.verify_bank, r.bank_released, r.ceiling_chapters from runs r
const q = `select b.workdir, r.verify_bank, r.bank_released, r.resnapshot, r.accept_rebill_micro,
r.ceiling_chapters from runs r
join books b on b.id = r.book_id where r.id = $1`
if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased, &out.CeilingChapters); err != nil {
var acceptRebill int64
if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased,
&out.Resnapshot, &acceptRebill, &out.CeilingChapters); err != nil {
return LiveRun{}, fmt.Errorf("pgstore: read restarted run: %w", err)
}
out.AcceptRebill = money.MicroUSD(acceptRebill)
return out, nil
}

View file

@ -87,7 +87,17 @@ func (t CeilingTemplate) Args(ceiling money.MicroUSD) ([]string, error) {
// keys never pass through this process. Empty means the flag is not passed and the engine falls back
// to its conventional `.env` files — which on the SaaS path nothing writes, so a deployment that
// leaves this unset is one whose runs fail at the first provider call.
func TranslateArgs(workdir string, verifyBank bool, keysFile string, ceiling []string) []string {
//
// resnapshot and acceptRebill are the re-pass consents (P10, D39.165 §3), decided at ADMISSION and
// stored on the run row — this function only renders what the row says, so a respawned attempt
// carries the same argv. They are the engine's own ORTHOGONAL pair (tmctl: --resnapshot re-pins
// jobs onto the current snapshot, --accept-rebill consents to the re-payment): a run over a moved
// bank needs both, because the snapshot guard and the consent gate refuse independently. The
// consent is always CAPPED — `--accept-rebill=<usd>`, the concrete sum the admission agreed to —
// never the bare blanket form: a projection grown past it (deploy drift on top of the bank's move)
// must refuse, not be bought silently. Zero means no consent and no flag.
func TranslateArgs(workdir string, verifyBank bool, keysFile string, resnapshot bool,
acceptRebill money.MicroUSD, ceiling []string) []string {
args := []string{"translate", "--config", filepath.Join(workdir, ConfigFile)}
if verifyBank {
args = append(args, "--verify-bank")
@ -95,6 +105,12 @@ func TranslateArgs(workdir string, verifyBank bool, keysFile string, ceiling []s
if keysFile != "" {
args = append(args, "--keys-file", keysFile)
}
if resnapshot {
args = append(args, "--resnapshot")
}
if acceptRebill > 0 {
args = append(args, "--accept-rebill="+acceptRebill.USD())
}
return append(args, ceiling...)
}

View file

@ -174,14 +174,14 @@ func TestTheCeilingIsRefusedUntilTheEngineCanBeToldIt(t *testing.T) {
// tmctl requires --config on every command that touches a book, so an invocation without it never
// reaches the book at all — measured on a binary built from HEAD.
func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) {
tr := TranslateArgs("/srv/books/a", true, "", []string{"--ceiling-usd", "1.000000"})
tr := TranslateArgs("/srv/books/a", true, "", false, 0, []string{"--ceiling-usd", "1.000000"})
if !slices.Contains(tr, "--config") || !slices.Contains(tr, "/srv/books/a/book.yaml") {
t.Errorf("translate argv without --config: %v", tr)
}
if !slices.Contains(tr, "--verify-bank") {
t.Errorf("verify_bank did not reach the engine: %v", tr)
}
if slices.Contains(TranslateArgs("/w", false, "", nil), "--verify-bank") {
if slices.Contains(TranslateArgs("/w", false, "", false, 0, nil), "--verify-bank") {
t.Error("verify-bank was sent for a run that did not ask for it")
}
st := StatusArgs("/srv/books/a")
@ -196,12 +196,12 @@ func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) {
//
// Mutation caught: dropping the argument, or passing the flag with an empty value.
func TestTheDeploymentKeyFileReachesTranslate(t *testing.T) {
tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", nil)
tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", false, 0, nil)
i := slices.Index(tr, "--keys-file")
if i < 0 || i+1 >= len(tr) || tr[i+1] != "/etc/tm/keys.env" {
t.Errorf("the key file did not reach the engine: %v", tr)
}
if slices.Contains(TranslateArgs("/srv/books/a", false, "", nil), "--keys-file") {
if slices.Contains(TranslateArgs("/srv/books/a", false, "", false, 0, nil), "--keys-file") {
t.Error("an unset key file was still passed as a flag")
}
}
@ -264,3 +264,23 @@ func TestQuoteArgvEscapesWhatWouldSplit(t *testing.T) {
t.Errorf("quoted argv: %s", got)
}
}
// The re-pass consents render exactly and only when granted (P10): --resnapshot bare, the consent
// always CAPPED to the concrete sum — the bare blanket form must never appear.
func TestTheConsentFlagsRenderExactlyWhenGranted(t *testing.T) {
tr := TranslateArgs("/w", false, "", true, 1_230_000, nil)
if !slices.Contains(tr, "--resnapshot") {
t.Errorf("no --resnapshot: %v", tr)
}
if !slices.Contains(tr, "--accept-rebill=1.230000") {
t.Errorf("the consent is not the capped sum: %v", tr)
}
for _, a := range TranslateArgs("/w", false, "", false, 0, nil) {
if a == "--resnapshot" || a == "--accept-rebill" || strings.HasPrefix(a, "--accept-rebill=") {
t.Errorf("an ungranted consent reached the argv: %v", a)
}
}
if slices.Contains(TranslateArgs("/w", false, "", true, 0, nil), "--accept-rebill=0.000000") {
t.Error("a zero consent rendered a flag: zero means no consent")
}
}

View file

@ -0,0 +1,175 @@
package runner
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"textmachine/platform/internal/ingest"
)
// The P10 mine, reproduced against the REAL engine (D39.165 §3; workflow errata 28.08-и): a bank
// correction moves the snapshot, and the engine's guard stops a CONTINUING run loudly unless
// `--resnapshot` travels — which this platform did not pass until P10. The state must be the
// errata's: the edit jobs EXIST before the correction (a finished book is the flagship case) —
// against fresh jobs the guard is silent and the reproduction is green without the fix, proving
// nothing. That precondition is therefore ASSERTED, not assumed: the no-flag run must die, and die
// naming the flag.
//
// Free of provider keys and of paid calls: the pipeline is the deployment's local $0 pair, served
// by an in-test OpenAI-compatible stub (the P9 live-probe's provider, ported); the guard itself
// fires before any model call.
//
// Mutation caught: TranslateArgs dropping either consent flag; the guard notice changing shape.
func TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem(t *testing.T) {
bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN")
tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE")
if bin == "" || tpl == "" {
t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " +
"the snapshot-guard mine is not reproduced against a real engine")
}
// The deployment's models.yaml points the $0 pair at this fixed address; a busy port means
// another stand is live on this host — skip loudly rather than fight it.
ln, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:11434")
if err != nil {
t.Skipf("the local provider address is busy (another stand?): %v", err)
}
srv := &http.Server{Handler: http.HandlerFunc(fakeProvider)}
go func() { _ = srv.Serve(ln) }()
defer srv.Close()
dir := t.TempDir()
writeProbeBook(t, tpl, dir, "bk_MINEPROBE")
// Chapter 1 carries the term (its units re-render after the correction and re-translate at $0
// stub prices); chapter 2 does not (its units are the $0 re-pin the flag permits).
source := "第一章 山路\n\n方源走在山路上方源看着远方的云。\n\n第二章 夜色\n\n老人在夜色中叹息风吹过山谷。\n"
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte(source), 0o644); err != nil {
t.Fatal(err)
}
ceiling := []string{"--ceiling-usd", "1.000000"}
run := func(args []string) (int, string) {
cmd := exec.CommandContext(t.Context(), bin, args...)
cmd.Dir = dir
out, _ := cmd.CombinedOutput()
return cmd.ProcessState.ExitCode(), string(out)
}
// Prime: a full run through BOTH waves. After it the edit jobs exist — the errata's state.
if code, out := run(TranslateArgs(dir, false, "", false, 0, ceiling)); code != 0 {
t.Fatalf("the priming run failed (%d): %s", code, lastLines(out, 6))
}
// The bank moves through the live verb — the exact door P9 landed.
doc, err := ingest.EncodeDecisions("bk_MINEPROBE", []ingest.BankDecision{
{Action: "approve", Src: "方源", Sense: "", Dst: "Фан Юань-P10", Kind: "name", Note: "P10 mine probe"},
})
if err != nil {
t.Fatal(err)
}
decisions := filepath.Join(t.TempDir(), "decisions.json")
if err := os.WriteFile(decisions, doc, 0o600); err != nil {
t.Fatal(err)
}
if out, err := New(nil).BankApply(t.Context(), bin, dir, decisions, false); err != nil ||
out.ExitCode != 0 || !out.Report.Changed {
t.Fatalf("the live correction did not land: %+v (%v; stderr %q)", out, err, out.Stderr)
}
// Post-fix invariant, half one — and the errata's precondition in the same breath: WITHOUT the
// flag the continuing run dies loudly, naming the flag. A green run here means the edit jobs
// did not exist and this fixture proves nothing about the mine.
code, out := run(TranslateArgs(dir, false, "", false, 0, ceiling))
if code == 0 {
t.Fatal("no snapshot guard fired on a moved bank: the edit-job precondition is not met " +
"(errata 28.08-и) and this reproduction is degenerate")
}
if !strings.Contains(out, "--resnapshot") {
t.Fatalf("the guard's stop does not name --resnapshot (%d): %s", code, lastLines(out, 6))
}
// Half two: the flags the platform now stores on the run row and renders through TranslateArgs
// carry the same run through — re-pins at $0, re-translates the term's units at stub prices.
// The engine names its pre-run restore point by the SECOND; on this $0 stub two translates fit
// in one, and the second refuses to overwrite the first's backup — wait the second out.
time.Sleep(1100 * time.Millisecond)
// The consent travels live too — the funded cap the platform renders (P10): the projection here
// is $0 (stub prices), well under the cap, so the engine accepts and the flag's whole live path
// is exercised rather than left to the argv pin alone.
if code, out := run(TranslateArgs(dir, false, "", true, 30_000, ceiling)); code != 0 {
t.Fatalf("with the consents the run still dies (%d): %s", code, lastLines(out, 6))
}
}
func lastLines(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
// fakeProvider is the P9 live-probe's $0 stand-in (fake_provider.py), ported: prose for the
// translator and editor, TSV for the terminology roles. The probe proves the CHAIN, not quality.
func fakeProvider(w http.ResponseWriter, r *http.Request) {
var req struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
var system, user strings.Builder
for _, m := range req.Messages {
switch m.Role {
case "system":
system.WriteString(m.Content)
case "user":
user.WriteString(m.Content)
}
}
content := proseFor(system.String(), user.String())
resp := map[string]any{
"id": "fake-1", "object": "chat.completion", "model": req.Model,
"choices": []map[string]any{{
"index": 0,
"message": map[string]any{"role": "assistant", "content": content},
"finish_reason": "stop",
}},
"usage": map[string]any{"prompt_tokens": 100, "completion_tokens": max(1, len(content)/3),
"total_tokens": 100 + max(1, len(content)/3)},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
var termKeys = regexp.MustCompile(`(?m)^key: (.+)$`)
func proseFor(system, user string) string {
if strings.Contains(system, "терминолог") {
var b strings.Builder
for i, m := range termKeys.FindAllStringSubmatch(user, -1) {
fmt.Fprintf(&b, "%s\tЗаглушка-%d\t55\n", m[1], i+1)
}
return b.String()
}
if strings.Contains(system, "классиф") || strings.Contains(system, "класс (name | place | title | term)") {
var b strings.Builder
for _, m := range termKeys.FindAllStringSubmatch(user, -1) {
fmt.Fprintf(&b, "%s\tname\n", m[1])
}
return b.String()
}
const filler = "Фан Юань неторопливо шёл по горной тропе, и ветер приносил запах трав. " +
"Старейшина посмотрел на него и тяжело вздохнул, вспоминая давние годы. " +
"В долине клубился туман, и где-то вдалеке кричала ночная птица. "
out := filler
for len(out) < len(user) {
out += filler
}
return out[:max(len(user), len(filler))]
}

View file

@ -173,6 +173,22 @@ func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsIn
return BankReceipt{}, err
}
rec, err := s.bankVerdict(ctx, in, out)
if err == nil && !in.Preview && corrected(rec) {
// The fact «the bank moved» is stamped from the door's OWN receipt — the one thing that
// cannot be blind to what the door just did. Asking the engine's status here was the first
// edition's mistake (adversarial pass, K1): status projects the STORED memory, and a
// correction reaches it only when the next translate folds the bank in — right after an
// apply it honestly answers «nothing moved».
if ferr := s.Store.RecordBankMove(ctx, in.BookID); ferr != nil {
// The correction LANDED — the engine's files moved — but the fact did not reach the
// store, and the next run's consents are decided from it. «Re-send the same document»
// is the honest remedy and it CONVERGES: the engine answers the re-send with its byte
// no-op, whose receipt reads already_applied — which is exactly why corrected() accepts
// that state too, or a failed write here would never be retried into place.
s.log().ErrorContext(ctx, "a correction landed but the bank-move fact did not", "err", ferr)
return BankReceipt{}, ErrBankIncomplete
}
}
var refused *ErrBankRefused
if errors.As(err, &refused) {
// The engine's cap refusals echo the document's PATH — this platform's own temp file. File
@ -281,6 +297,22 @@ func (s *Service) bankReceipt(in BankCorrectionsInput, rep ingest.BankReport) (B
return out, nil
}
// corrected answers whether this receipt proves the bank carries the document: a write that
// changed files, or a retry whose every accepted state reads already_applied — the byte no-op of
// a document that landed before. Both prove the move; a no-op that neither changed nor re-found
// anything proves none.
func corrected(rec BankReceipt) bool {
if rec.Changed {
return true
}
for _, a := range rec.Accepted {
if a.State == "already_applied" {
return true
}
}
return false
}
// SweepCorrectionScratch removes decision documents an unclean death left behind: cleanup rides a
// defer and dies with the process (SIGKILL, OOM, a deploy's expired grace), nothing else deletes
// by this mask, and each orphan carries up to a megabyte of a user's own corrections sitting in

View file

@ -387,3 +387,250 @@ func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) {
t.Error("the sweep touched a file outside its mask")
}
}
// The bank-move fact is stamped by an APPLY from the door's OWN receipt and never by a preview:
// the engine's status projection is blind to a correction until the next translate folds the bank
// in (errata 28.08-к), so the receipt — changed, or already_applied on a retry — is the only
// witness that cannot lie about what the door just did (P10 §3.1).
func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) {
f := newFixture(t, "10", 500)
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("projection"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: true,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
if err != nil {
t.Fatal(err)
}
if book.BankMoved {
t.Fatal("a PREVIEW recorded a bank move")
}
fake.mu.Lock()
fake.out.Report.Mode = "apply"
fake.mu.Unlock()
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved {
t.Fatalf("the apply left no fact: %+v (%v)", book, err)
}
// A retry of the same document answers changed:false with already_applied states — and still
// proves the move, which is what makes a failed fact-write retryable (corrected()).
retry := okReport("apply")
retry.Changed = false
retry.Accepted = []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", State: "already_applied"}}
retry.BookID = f.bookID(t)
if err := f.svc.Store.ClearBankMove(f.ctx, f.bookID(t)); err != nil {
t.Fatal(err)
}
fake.mu.Lock()
fake.out.Report = retry
fake.mu.Unlock()
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved {
t.Fatalf("an already_applied retry did not re-stamp the fact: %+v (%v)", book, err)
}
}
// A start over a moved bank decides BOTH consents once, stores them on the row, and the spawn
// renders them — the argv is the admission's decision, stable across respawns (P10 §3.1). The cap
// is FUNDED: the run's own hold, never a projection (which does not exist at admission).
func TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn(t *testing.T) {
f := newFixture(t, "10", 500)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2})
if err != nil {
t.Fatal(err)
}
if err := f.svc.Spawn(f.ctx, run.ID); err != nil {
t.Fatal(err)
}
f.runner.mu.Lock()
spec := f.runner.started[len(f.runner.started)-1]
f.runner.mu.Unlock()
joined := strings.Join(spec.Args, " ")
if !strings.Contains(joined, "--resnapshot") {
t.Errorf("the spawn carries no --resnapshot: %q", joined)
}
// Two chapters at the fixture's default rate: the consent IS the hold, funded by construction.
if !strings.Contains(joined, "--accept-rebill=0.060000") {
t.Errorf("the spawn's consent is not the run's own hold: %q", joined)
}
}
// The fact is retired EXPLICITLY and only by success: a run that died on the guard (exit 1, $0)
// leaves it standing — so the next admission still carries the flags instead of looping the guard
// forever (adversarial K6) — and a ready resnapshot run clears it.
func TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt(t *testing.T) {
f := newFixture(t, "10", 500)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "1", At: f.now.Add(2 * time.Second)})
book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t))
if err != nil {
t.Fatal(err)
}
if !book.BankMoved {
t.Fatal("a FAILED run retired the fact: the next run would die on the guard with no flags")
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "0", At: f.now.Add(3 * time.Second)})
if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || book.BankMoved {
t.Fatalf("a READY resnapshot run did not retire the fact: %+v (%v)", book, err)
}
}
// The re-pass door (P10 §3.2, the deferred-projection form): on a book whose bank moved, a
// re-pass admission PASSES — a zero-chapter row whose hold is the whole book's chapter price
// («up to your hold»; untouched units come back at $0 and the difference is released) — and on a
// book without the move it refuses with its own word.
func TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne(t *testing.T) {
f := newFixture(t, "10", 5)
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); !errors.Is(err, ErrRePassUnavailable) {
t.Fatalf("a re-pass with no moved bank answered %v, want ErrRePassUnavailable", err)
}
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true})
if err != nil {
t.Fatalf("the re-pass door refused a moved bank: %v", err)
}
var chapters int
var hold int64
if err := f.store.Pool().QueryRow(f.ctx,
`select ceiling_chapters from runs where id = $1`, run.ID).Scan(&chapters); err != nil {
t.Fatal(err)
}
if err := f.store.Pool().QueryRow(f.ctx,
`select amount_micro_usd from credit_ledger where kind = 'hold' order by id desc limit 1`).
Scan(&hold); err != nil {
t.Fatal(err)
}
if chapters != 0 {
t.Errorf("a re-pass bought %d chapters, want 0", chapters)
}
// Five chapters at the fixture's default rate: the whole book's price, held; the unspent part
// is released on settlement.
if hold != -150_000 {
t.Errorf("the re-pass hold is %d micro-USD, want the whole book's -150000", hold)
}
}
// A RESUME over a moved bank grants the consents on re-open — a correction after a ceiling pause
// mid-edit is the mine's flagship shape — and they land on the ROW, where every later spawn of
// every attempt reads them (P10 §3.1).
func TestAResumeOverAMovedBankGrantsTheConsents(t *testing.T) {
f := newFixture(t, "10", 500)
runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil {
t.Fatal(err)
}
var resnapshot bool
var consent int64
if err := f.store.Pool().QueryRow(f.ctx,
`select resnapshot, accept_rebill_micro from runs where id = $1`, runID).
Scan(&resnapshot, &consent); err != nil {
t.Fatal(err)
}
// The funded cap: the run's whole budget, three chapters at the fixture's rate.
if !resnapshot || consent != 90_000 {
t.Fatalf("the resumed row carries resnapshot=%v consent=%d, want true/90000", resnapshot, consent)
}
}
// A re-pass is bought again, not resumed (P10, adversarial K4): its budget is not chapter-derived,
// and an interrupted one leaves the fact standing — the purchase is simply available again.
func TestARePassIsBoughtAgainNotResumed(t *testing.T) {
f := newFixture(t, "10", 5)
f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0,
runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)})
fake := &fakeBankApplier{
out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true},
}
fake.out.Report.BookID = f.bookID(t)
f.svc.Bank = fake
if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{
UserID: "u1", BookID: f.bookID(t), Preview: false,
Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}},
}); err != nil {
t.Fatal(err)
}
run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true})
if err != nil {
t.Fatal(err)
}
// The re-pass is interrupted: the row closes as stopped, the fact stays (only ready retires it).
if _, err := f.store.Pool().Exec(f.ctx,
`update runs set status = 'stopped', finished_at = $2, stop_requested_at = $2, settled_at = $2
where id = $1`, run.ID, f.now.Add(2*time.Second)); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrNotResumable) {
t.Fatalf("resuming a re-pass answered %v, want ErrNotResumable (bought again)", err)
}
if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); err != nil {
t.Fatalf("re-buying the interrupted re-pass was refused: %v", err)
}
}

View file

@ -659,6 +659,18 @@ func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker
return nil
}
s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result)
if l.Resnapshot && status == "ready" {
// The bank-move fact is retired EXPLICITLY, and only here: a run that carried the consents
// and finished clean has demonstrably walked the correction into the text. Every other
// ending — failed, stopped, paused — leaves the fact standing, so the next admission still
// carries the flags instead of dying on the engine's snapshot guard (adversarial K6: a
// timestamp predicate let a failed run retire the fact and loop the guard forever). The
// write is outside FinishRun's transaction on purpose: if it fails, the stale fact costs
// one harmless --resnapshot on the next run, never a dead one.
if err := s.Store.ClearBankMove(ctx, l.BookID); err != nil {
s.log().ErrorContext(ctx, "the finished re-snapshot run could not retire the bank-move fact", "err", err)
}
}
// Settling immediately rather than waiting for the next sweep: the hold is the account's money
// and every second it stays reserved is a second the user cannot start another book. The text the
// run produced is NOT materialized here: `FinishRun` recorded that debt in the transaction that
@ -1110,9 +1122,29 @@ func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun, from liveness)
if s.Cfg.AllowEngineVersionChange && s.Cfg.EngineBinary != "" {
version = &s.Cfg.EngineBinary
}
// The re-pass consents, granted on the USER's resume alone (P10 §3.1): a resume of a run the
// bank moved under respawns into the engine's snapshot guard, and without the flags it dies
// loudly after the money moved. The cap is the run's own full budget — funded consent, the same
// figure Start grants (a projection to cap against does not exist here; errata 28.08-к). The
// reconciler's restarts (fromALiveRun) pass zeroes and change nothing: their run was admitted
// with its consents already on the row, and argv is the admission's decision, never a sweep's
// re-derivation.
resnapshot, consent := false, money.MicroUSD(0)
if from == fromAFinishedRun {
book, err := s.Store.ReadBookForRun(ctx, l.UserID, l.BookID)
if err != nil {
return pgstore.LiveRun{}, deferred, err
}
if book.BankMoved {
resnapshot = true
consent = s.Pricing.Ceiling(l.CeilingChapters)
}
}
next, err := s.Store.RestartRun(ctx, pgstore.RestartInput{
RunID: l.RunID,
AttemptID: l.AttemptID,
RunID: l.RunID,
AttemptID: l.AttemptID,
Resnapshot: resnapshot,
AcceptRebill: consent,
// The reconciler's own guard against a stale snapshot: if another pass finished this run
// while this one was settling, it must not be brought back to life.
OnlyIfLive: bool(from),
@ -1236,6 +1268,14 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run
}
return pgstore.Run{}, fmt.Errorf("%w: a newer run of this book exists, and the book's screens follow that one", ErrNotResumable)
}
if l.CeilingChapters == 0 {
// A re-pass is bought again, not resumed. Its budget is not chapter-derived, so reopen's
// remaining-budget arithmetic has nothing to compute (Ceiling(0)=0 read as «spent» — the
// adversarial pass's K4, which also sealed the door); and nothing needs resuming: an
// interrupted re-pass leaves the fact standing (only a READY resnapshot run retires it), so
// the purchase is simply available again.
return pgstore.Run{}, fmt.Errorf("%w: a re-pass is bought again rather than resumed", ErrNotResumable)
}
switch l.Status {
case "stopped":
case "awaiting_bank":

View file

@ -705,7 +705,7 @@ func (f *fixture) owed(t *testing.T) []pgstore.OwedBook {
//
// Mutation caught: passing l.VerifyBank unconditionally.
func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) {
before := runner.TranslateArgs("/srv/books/bk1", true, "", nil)
before := runner.TranslateArgs("/srv/books/bk1", true, "", false, 0, nil)
if !slices.Contains(before, "--verify-bank") {
t.Fatal("a run that asked for the signing stop is spawned without it")
}

View file

@ -136,6 +136,11 @@ func (s *Service) log() *slog.Logger {
// read and this call, because a hold taken for another book lowers what is left.
var ErrCeilingOutOfBounds = errors.New("runs: the requested ceiling is outside the bounds")
// ErrRePassUnavailable — a re-pass was asked for on a book that has nothing to re-pass: the bank
// has not moved since the last run, or its move touched no already-paid unit. The remedy is the
// options read, which is what says whether a re-pass exists to buy (P10 §3.2).
var ErrRePassUnavailable = errors.New("runs: the book has nothing to re-pass")
// ErrRunnerIncomplete is a DEPLOYMENT that cannot start runs — the exit-marker command is missing,
// so a unit that ended would have no way to say so and its hold would stay reserved forever.
//
@ -225,6 +230,11 @@ type StartRequest struct {
BookID string
VerifyBank bool
CeilingChapters int
// RePass buys the re-pass instead of chapters (P10, D39.165 §3): legal only when the options
// announced work (the bank moved and touched paid units), it admits a zero-chapter run whose
// hold is the materialized projection and whose bar walks the book (the re-pass form). The
// wire's carrier is the contract half's `re_pass` member; CeilingChapters is ignored with it.
RePass bool
}
// Start admits a run.
@ -265,6 +275,50 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
if err != nil {
return pgstore.Run{}, err
}
// The re-pass consents, decided HERE — under the book lock, once — and stored on the run row
// so every spawn of every attempt renders the same argv (P10 §3.1). A run admitted over a moved
// bank needs both engine flags: without --resnapshot the snapshot guard stops it loudly AFTER
// the hold, and without a capped --accept-rebill the consent gate does the same on any
// correction worth more than the ~half-cent threshold. The cap is FUNDED: the run's own hold —
// «re-pay no more than this run may spend at all» — because a projection to cap against does
// not exist at admission time (the engine's status is blind to a correction until the next
// translate folds the bank in; errata 28.08-к) and an unfunded cap let a run burn its whole
// budget on re-billing (adversarial K7). Never the bare blanket form.
resnapshot, consent := false, money.MicroUSD(0)
if book.BankMoved {
resnapshot = true
consent = s.Pricing.Ceiling(in.CeilingChapters)
}
if in.RePass {
// The re-pass purchase (P10 §3.2, the deferred-projection form): «a re-pass costs up to
// your hold». What it would actually cost is unknowable at admission (errata 28.08-к), so
// the hold is the honest ceiling of the work bought — the whole book's chapter price, of
// which untouched units come back at $0 and the difference is released on settlement. The
// consent cap equals the hold: funded by construction, strictly positive because a
// translatable book has chapters.
if !book.BankMoved {
return pgstore.Run{}, ErrRePassUnavailable
}
offset, err := journalSize(book.Workdir)
if err != nil {
return pgstore.Run{}, err
}
hold := s.Pricing.Ceiling(max(book.ChapterCount, 1))
started, err := s.Store.StartRun(ctx, pgstore.StartRunInput{
UserID: in.UserID,
BookID: in.BookID,
CeilingChapters: 0,
Ceiling: hold,
Now: s.now(),
Resnapshot: true,
AcceptRebill: hold,
}, offset, s.enqueue)
if err != nil {
return pgstore.Run{}, err
}
s.log().InfoContext(ctx, "re-pass admitted", "run", started.ID)
return started.Run, nil
}
bounds := s.Pricing.Scale(acct.Balance, book.ChaptersLeft)
if in.CeilingChapters < bounds.Min || in.CeilingChapters > bounds.Max {
// WHY the scale does not fit decides what the client can offer next: bounds that moved are
@ -289,6 +343,8 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro
CeilingChapters: in.CeilingChapters,
Ceiling: s.Pricing.Ceiling(in.CeilingChapters),
Now: s.now(),
Resnapshot: resnapshot,
AcceptRebill: consent,
}, offset, s.enqueue)
if err != nil {
return pgstore.Run{}, err

View file

@ -167,7 +167,8 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec,
// the flag takes the
// engine's auto path (pipeline/mining.go, D39.42 п.3): the run continues and undecided
// rows ride into the bank marked ⟨проверить⟩ (unified backlog row 191).
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile, ceiling),
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile,
l.Resnapshot, l.AcceptRebill, ceiling),
Workdir: l.Workdir,
Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)),
ExitMarker: marker,