textmachine/docs/architecture/15-money-path.md

187 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Путь денег — от гранта до settle (топикальный вход, строка 167)
> **Статус: топикальный ВХОД, НЕ источник истины.** Док собирает денежную тему, размазанную по D-нотам, реестру дефектов платформы, глоссарию и бэклогу, в один маршрут чтения. При конфликте с чем угодно побеждает `docs/architecture/05-decisions-log.md` (правило чтения — диета D39.125: живой файл + слайсы). Каждое утверждение здесь снабжено указателем на первичный носитель; формулы — дословно оттуда. Словарь терминов (холд · прирост · аргумент потолка · baseline · settle · leftover-reserved · SpendBound) — `docs/glossary.md` §«Деньги платформы» (`docs/glossary.md:55`=`**Деньги платформы:**`).
>
> ⚠ **Форма указателей — норма этого дока (31.08, пере-проверены ВСЕ указатели: 34 уехали, 1 мёртв, 16 верны).**
> **Стабильна не ЗОНА, а ЧИСТЫЙ ФАЙЛ.** Номер строки ставится только туда, где `git status <цель>` показывает файл чистым; файл с незакоммиченной правкой — чей угодно, включая `docs/` — получает греп-указатель без номера. Ось именно такая — ратифицировано **D39.179 п.4**, который уточняет D39.177 §2в и прямо объявляет зонную ось НЕВЕРНОЙ (там же: «Носитель нормы — шапка 15-money-path.md»), и зонная ось её НЕ заменяет: `docs/architecture/14-api-contract/openapi.yaml` ЛЕЖАЛ в «своей» зоне и при этом ДВИГАЛСЯ паком контракта 0.9.0 (заленджен 31.08, `0a680a3`, D39.180).
> Токен-форма — `` `путь:N`=`ожидаемая подстрока` ``; её гейт `docs/scripts/counts.py --lint` сверяет ПО СОДЕРЖИМОМУ и краснеет, когда цель уезжает.
> ⚠ **Что гейт берёт с якоря БЕЗ токена:** существование файла и что номер не больше длины файла; у голого имени журнала решений — ещё и структуру (владелец строки обязан совпасть с названной рядом нотой). НЕ проверяется только СОДЕРЖИМОЕ — вот почему 34 якоря уехали молча. Голое имя без «/» не судится вовсе.
> ⚠ **Греп-форма не проверяется гейтом никак** (регексп якоря требует `:номер`) — осознанный размен: переживает чужой WIP, но молчит.
> ⚠ **Токен НЕ должен содержать бэктик:** регексп берёт токен как «всё до следующего бэктика» и обрезает хвост, а необрезанная часть не проверяется вовсе.
## 1. Канон одним абзацем
Деньги видимы с первого вызова: телеметрия, потолки, явное согласие на пере-оплату (CLAUDE.md, цель 5). Леджер без потолка запрещён — Р7: книга обязана нести хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:332`=`a ledger with no ceiling is forbidden`, текст валидатора: «a ledger with no ceiling is forbidden, Р7»). Суммы на пользовательский экран НЕ выходят — запрет D39.84, подтверждён D39.100/К-8 (дословно: «страница лимитов/использования в настройках (СТАТУС, не суммы — запрет денег на экране D39.84 в силе и НЕ superseded)» — `docs/architecture/05-decisions-log.md`, греп `^## D39.100` → пункт К-8, дословная подстрока `СТАТУС, не суммы — запрет денег на экране`; ⚠ адресуем НОМЕРОМ ноты, а не строкой журнала) и держится в контракте (`docs/architecture/14-api-contract/openapi.yaml`, греп `State of the credit balance. No window` — ⚠ номер не ставим: контракт переверстывается минорами, греп-форма дешевле поддержки). Защита от перерасхода двойная и не зависит от событий: холд платформы делает кредит недоступным следующему прогону, а движок сам останавливается на потолке — «an overspend is impossible even while the platform is blind. The event stream is freshness only» (док-коммент `Hold``platform/internal/pgstore/credits.go`, греп `an overspend is impossible even`).
## 2. Деньги ДВИЖКА (зона `backend/`)
- **Леджер SQLite.** Таблица `spend`, две фигуры: `committed_usd` (потрачено, с сырым ответом провайдера) и `reserved_usd` (зарезервировано под летящий вызов). `Reserve` перед каждым платным вызовом сверяет `SUM(committed_usd + reserved_usd)` книги и дня с потолками (`backend/internal/store/ledger.go:44,58,63`=`func (s *Store) Reserve(`; Р7 — коммент `backend/internal/store/ledger.go:12`=`per-day (Р7); checked against`); `SettleWithCheckpoint` одной транзакцией конвертирует резервацию в committed и персистит ответ (`backend/internal/store/ledger.go:178`=`func (s *Store) SettleWithCheckpoint(`). ⚠ У ДВИЖКОВОГО `store.Store` метода `Settle` нет — он называется `SettleWithCheckpoint` (словарь кода: `backend/internal/store/ledger.go:11`=`Reserve/Settle/ReleaseReservation semantics`). ⚠ Не спутать с ПЛАТФОРМЕННЫМ `credits.Settle` (§3 п.8) — это другой `Store`. Суммарно по книге — `SpentUSD` (`backend/internal/store/ledger.go:392`=`SpentUSD reports (committed, reserved)`).
- **Гейт потолков — ПЕР-ВЫЗОВНЫЙ** (`Reserve` на каждый свежий attempt). На границе юнита сидел РЕПЭЙР-суб-бюджет — ужесточён до пер-вызовного с ценой вызова (строка 135 закрыта D39.131); эскалационный кап хоп НЕ прицениваает — перелёт ≤1 хопа, задокументирован и запинен (диспозиция D39.131 п.2д, реопен — живой инцидент). ⚠ **ТРЕТЬЕ семейство, которого перечень не знал (доп. ревизией 02.09): банк-роли несут СОБСТВЕННЫЕ КНИГО-ШИРОКИЕ бюджеты**`gates.terminology.budget_usd` и `gates.terminology.classify_budget_usd`. Именно оно резало ОПЛАЧЕННУЮ работу на холодном прогоне 31.08 (D39.182 §4: инцидент был на классификаторе). С 31.08 (D39.182) план прохода режется ценой партии ДО первого вызова, а не обрывается посередине; усечение ВИДНО в отчёте — поля `BatchesDropped` и `ClassifyBatchesDropped` (`backend/internal/pipeline/terminologist.go`, греп `BatchesDropped`). ⚠⚠ **И сами цифры этих суб-бюджетов в книжных конфигах КАЛИБРОВАНЫ ПОД ИЮЛЬСКИЕ ЦЕНЫ** (тот же множитель ×4.47, D39.179 п.1): на холодном прогоне 31.08 `classify_budget_usd` 0.02 оборвал классификатор дважды, а поднятый до 0.08 `escalation.budget_usd` был пробит фактом до 0.103305. То есть суб-бюджеты режут ОПЛАЧЕННУЮ работу не по замыслу, а по протухшей калибровке.
- **leftover-reserved зануляется write-open.** `store.Open` (путь записи, каждый `translate`) выполняет `recoverReservations` (`backend/internal/store/store.go:110`=`s.recoverReservations(ctx)`; сама функция — `backend/internal/store/store.go:278-279`=`UPDATE spend SET reserved_usd = 0`) — файл владеется одним процессом, значит любой reserved на открытии принадлежит несеттлённому прогону. `OpenReadOnly` этого прохода намеренно НЕ делает (`backend/internal/store/store.go:124`=`does not run that pass`) ⇒ **reserved, увиденный read-only `status` В МОМЕНТ СПАВНА, — остаток мёртвого процесса** (несущий факт формулы PD-158, см. §3). ⚠ Но НЕ «всегда»: `OpenReadOnly` построен ровно затем, чтобы `status` работал ВО ВРЕМЯ живого прогона (`backend/internal/store/store.go:122`=`allows concurrent readers while a writer is live`), и конкурентный `status` покажет ЖИВУЮ резервацию между `Reserve` и settle. Узко формулирует и сам код: «after a run crashes, reserved_usd stays non-zero until the next WRITE command» (`backend/internal/store/store.go:130`=`after a run crashes, reserved_usd stays non-zero`), и платформа — «at spawn there is no other writer … so anything reserved is by construction a leftover, never a live promise» (`platform/internal/runs/spawn.go`, греп `never a live promise`).
- **`--max-units` — ОБЪЁМНЫЙ потолок прогона, ортогональный денежному** (D39.165 §1б, принят D39.170). Ограничивает не деньги, а РАБОТУ: не больше N выходных ЮНИТОВ (гранулярность `units_total` манифеста — та же, в которой платформа продаёт главы) возьмут СЛОТ гранта в этом прогоне; юниты, отданные за $0 (резюм, ре-пин), ретраи и эскалации внутри юнита потолок не тратят. ⚠ **«Слот» ≠ «оплата», и с 03.09 это РАЗНЫЕ числа** (пак «число согласия», строка бэклога 232): юнит, который прежний прогон НАЧАЛ и не отгрузил, дописывается ВНЕ гранта, поэтому грант N оплачивает ДО 2N выходных юнитов — замерено приёмкой на живом раннере (грант 2 → `Paid()=4`, шесть вызовов провайдера). Единственный денежный бонд здесь — `--ceiling-usd`. ⛔ **Семантика переноса НЕ ратифицирована — слово владельца 03.09 «подумаем на этот счёт»**, и до его решения читать это как замеренный факт, а не как норму. Принимает только `translate`. **Остановка по объёму — ЗАВЕРШЕНИЕ (exit 0), не пауза:** словарь кодов выхода не расширялся и нового значения `Finished.Outcome` тоже нет — ⚠ **но с 31.08 признак едет ЧИСЛАМИ в кадре `finished` шва, а не только прозой** (D39.181 п.2, закон раскрытия: прозаическая строка отчёта до потребителя потока не доезжала): носитель — `Finished.Volume`, леджер доставки. Различение при этом живёт и в отчёте прогона, и в логе; отчёт разводит ДОСТАВКУ и ПЕРЕ-ДЕЛКУ. Носитель — `backend/internal/pipeline/volume.go:13`=`the VOLUME ceiling — the run's second stop`; словарь флага дословно — `backend/cmd/tmctl/invocation.go:143`=`Stopping on it is a COMPLETION (exit 0), not a pause`. ⚠ Проводка в платформу ГЕЙЧЕНА (`PD-422`): единственный писатель признака движения банка — дверь правок, рост АВТО-банка от майнинга флага не ставит.
- **`--ceiling-usd` — КНИЖНЫЙ потолок, не бюджет прогона.** Дословно из флага: «the book USD ceiling in force for THIS RUN ONLY — it OVERRIDES book.yaml `ceilings.book_usd` and is never written back. It caps the book's CUMULATIVE committed+reserved spend, not this run's increment…» (`backend/cmd/tmctl/invocation.go:142`=`the book USD ceiling in force for THIS RUN ONLY`; ⚠-коммент `backend/cmd/tmctl/main.go:260`=`--ceiling-usd is NOT a per-run budget`; многоточие закрывает обрыв цитаты — во флаге дальше стоит «, and must be > 0»). Ратификация — D39.122 п.2(в): пересчёт «пользовательский прирост → абсолют» — обязанность ПЛАТФОРМЫ, вторая денежная ось не заводится. День-потолок флаг НЕ перекрывает (PD-157). Проводка внутри: `Ceilings.BookUSD` в `Reserve` и именование сработавшего потолка в ошибке — `backend/internal/pipeline/stagerun.go:496`=`BookUSD: r.bookCeilingUSD()` и `backend/internal/pipeline/stagerun.go:525`=`overrides the book's ceilings.book_usd` (стоп = `errReserveCeiling`, `backend/internal/pipeline/escalation.go:49`=`var errReserveCeiling`).
- **Потолки — wiring, не семантика:** `Ceilings` намеренно исключены из `BriefHash` («Wiring fields (paths, ceilings, db) deliberately excluded», `backend/internal/config/book.go:345`=`Wiring fields (paths, ceilings, db) deliberately excluded`; канон — `backend/internal/config/book.go:369`=`canon := struct {`) ⇒ смена ДЕНЕЖНОГО потолка не двигает ни снапшот, ни ре-билл (D39.110 п.2б). ⚠ Верно ровно про ДЕНЬГИ: потолок СЕГМЕНТАЦИИ `edit_ceiling_out` в снапшот ВХОДИТ и меняет границы чанков (`backend/internal/pipeline/snapshot.go:34`=`EditCeilingOut int`).
- **`status --json` отдаёт фигуры платформе:** `committed_usd`, `reserved_usd`, `book_ceiling_usd`, `ceiling_pct` = 100·(committed+reserved)/book_ceiling (`backend/internal/pipeline/status.go:311`=`book_ceiling_usd,omitempty`; арифметика `ceiling_pct``backend/internal/pipeline/status.go:835`=`100 * (committed + reserved)`). Дневной фигуры в status нет (PD-157 — `platform/docs/DEFECT_REGISTER.md`, греп `PD-157`).
- **Леджер = НИЖНЯЯ граница** (строка 78 бэклога, `docs/PROGRESS.md`, греп `Леджер денег = НИЖНЯЯ граница`): «2xx body decode failed (call IS billed)» — провайдер списал, попытка в `request_log` не попадает; живой замер D39.86 — 3 вызова из 14, неизвестность $0.015111 при леджере $0.114378; движок сеттлит оценку и печатает `estimated-cost rows: 3`. Канал виден, фикс — money-паком (дизайн GENERALITY_PHASE2 §5.6).
- **Согласие на пере-оплату — ДВИЖКОВЫЙ гейт**. Прогон, который пере-покупает уже оплаченные юниты дороже порога, ОСТАНАВЛИВАЕТСЯ и требует явного согласия. Порог по умолчанию ратифицирован D20.2-Q2: `min($0.50, 5% × ProjectedBookUSD)`. Носитель — `backend/internal/pipeline/rebill.go`, греп `rebillConsentThreshold`; единственная ручка книги — `rebill_consent_usd` (валидатор `backend/internal/config/book.go`, греп `rebill_consent_usd`; 0 = ратифицированный дефолт). ⚠ Порог считается БЕЗ контура банк-ролей (D39.182 §3), то есть срабатывает позже, чем подсказывает полная проекция.
## 3. Деньги ПЛАТФОРМЫ по шагам (зона `platform/`)
1. **Грант.** `Grant` — строка леджера `kind='grant'`, идемпотентная по `(аккаунт, source, source_id)`: `on conflict (user_id, source, source_id) do nothing` (`platform/internal/pgstore/credits.go`, греп `func (s *Store) Grant(`; сама идемпотентность после пака sqlc уехала В ЗАПРОС — `platform/internal/pgstore/queries/credits.sql`, греп `on conflict (user_id, source, source_id) do nothing`). Фри-тир — тот же механизм в ТОЙ ЖЕ транзакции, что создание аккаунта (`platform/internal/pgstore/identity.go`, греп `appendLedger(ctx, tx, userID, "grant"`; ⚠ **дефолт `SignupGrantMicroUSD` — НОЛЬ** (`platform/internal/config/config.go`, греп `SignupGrantMicroUSD:`), а НЕ «5 × 1_000_000» — эта сумма ходила по докам и ложна: предложение «на бете дефолт в НОЛЬ» (D39.110 п.3) ИСПОЛНЕНО, `PD-104` в регистре стоит `fixed(P7, дерево сессии)`).
2. **Выбор потолка — шкала в ГЛАВАХ** (D39.110: от 1 до максимума, ноль не выбираем; потолок принадлежит ПРОГОНУ, в `book.yaml` не пишется). **Максимум = Balance КАК ЕСТЬ** (D39.115 п.2а; эррата в шапке D-лога — `docs/architecture/05-decisions-log.md:4`=`Balance КАК ЕСТЬ`): `Hold` — дебет в момент взятия, баланс уже не содержит открытых холдов, повторное вычитание вдвое укорачивает шкалу. Реализация: `pricing.Model.Scale``affordable = balance / PerChapter`, кламп по остатку глав, дефолт = верх шкалы (ратифицировано D39.123 п.2е; компромисс «весь баланс под одну книгу» принят осознанно) — `platform/internal/pricing/pricing.go`, греп `func (m Model) Scale(`.
3. **Ставка $0.03/глава — конфиг-константа платформы:** `DefaultPerChapter = 30_000` micro-USD (`platform/internal/pricing/pricing.go`, греп `DefaultPerChapter`), переопределяется `PerChapterMicroUSD` (`platform/internal/config/config.go`, греп `PerChapterMicroUSD`). ⚠⚠ **ЧИСЛА НИЖЕ — ИЮЛЬСКИЕ ДЕНЬГИ И С 16.08 НЕ ДЕЙСТВУЮТ (испр. ревизией 02.09).** D39.179 п.1 ратифицировал: DeepSeek пере-пинен под цены, вступившие **16.08 16:00 UTC**, множитель к июльским деньгам **×4.47**. ⇒ по действующему прайсу та же выборка даёт среднюю главу **≈$0.051$0.085** и максимум **≈$0.168**, то есть константа $0.03 не завышена, а **ЗАНИЖЕНА**, и знак вывода перевернулся. ⚠ Иллюстрация живыми деньгами (НЕ калибровка): холодный прогон 31.08 дал полную цепь главы $0.093 и $0.151 при `projected_book_usd = 1.1377277707` — но это **n=2 главы из десяти**, прогон остановлен снапшот-гардом, промпт черновика был банкнотным вместо конвенционного, и сам отчёт запрещает такое употребление дословно (`archive/reports/COLDRUN_V16_REPORT_2026-08-31.md`, греп `калибровать ставку платформы`). **Провенанс ниже — ИСТОРИЯ (D39.165 §1, 28.08):** средняя **$0.0115$0.0190**, максимум одной главы $0.0375, «завышена в **1.62.6×**» — ⚠ но НЕ покрывает максимум: источник (D39.165 §1) добавляет «и отдельные главы её уже пробивают» ($0.0375 против $0.03 = ×1.25), и «купить 10 глав» отдаёт движку $0.30, покупающий ~1626 глав («денежная ручка с подписью „главы"», `PD-410`). РЕШЕНО: цена — от ОБЪЁМА ИСХОДНИКА (носитель `chapters.units_total`, пишется интейком за $0), настоящий стоп — объёмный потолок В ДВИЖКЕ (см. §2). ⛔ Константу по сегодняшним числам НЕ калибровать (D39.165 ⛔г) — гейт строка 202 бэклога (`docs/PROGRESS.md`, греп `Живой перевод книги НАСКВОЗЬ через API платформы`). Исторический провенанс: exp08 v2 $0.0219/глава → ревизия D30.4 +1525% → округление ВВЕРХ (низкая ставка опаснее высокой — прогон встанет на потолке посреди книги; `platform/internal/pricing/pricing.go`, греп `Provenance, so the number is auditable`). Временная мера беты — честная оценка движковая (строка 166 бэклога, `docs/PROGRESS.md`, греп `Движковая поверхность оценки`).
4. **Холд прироста ДО спавна.** `holdTx` пишет отрицательную строку `('hold', -amount)` и тем же знаком двигает кэш баланса (`platform/internal/pgstore/credits.go`, греп `func holdTx(` — строку леджера пишет `appendLedger(… "hold", -amount …)`, кэш баланса двигает `MoveBalance` внутри неё); нехватка — `ErrInsufficientCredit`. Холд + прогон + попытка + очередь — одна транзакция (`platform/internal/pgstore/runs.go`, греп `func (s *Store) StartRun(`; рестарт — греп `func (s *Store) RestartRun(`; форма D39.123 п.2д).
5. **Аргумент движку: `bookCap = committed + прирост×ставка`, БЕЗ reserved — PD-158, дословно из кода: `return m.committed + increment`** (`platform/internal/runs/spawn.go`, греп `return m.committed + increment`). Обе фигуры читаются ОДНИМ вызовом `status --json` перед стартом (`bookMeter``platform/internal/runs/spawn.go`, греп `func (s *Service) bookMeter(`; отказы — греп `Absent is not zero`); отсутствие любой из них = отказ спавна («absent is not zero», PD-40). Почему без reserved: write-open движка занулит leftover до первой резервации, значит цифра из read-only status к моменту сравнения уже мертва; ратифицированная ранее формула с reserved переплачивала запасом ровно на leftover сверх холда — исполнено обеими формулами против гейта (D39.123 п.2б, поправка к D39.122(в)). Аргумент ≠ холд: он больше холда на committed (`docs/glossary.md:55`=`ЭТО НЕ ХОЛД — он больше холда`). Фактически ушедшее значение хранится в `run_attempts.ceiling_arg_micro_usd` (PD-144, миграция 00011); потолок уходит argv-шаблоном `{{usd}}` (шаблон — `platform/internal/config/config.go`, греп `CeilingArg`; подстановку делает `platform/internal/runner/engine.go`, греп `{{usd}}`; подстановку и рендер argv делает `platform/internal/runner/engine.go`, греп `func (t CeilingTemplate) Args(`; `platform/internal/runs/runs.go`, греп `func (s *Service) ceilingFor(` — однострочная делегация к нему; `spawn.go` потолок только передаёт); строка 145 закрыта D39.122.
6. **Baseline и списание.** `baseline = committed` книги на старте попытки (`platform/internal/runs/spawn.go`, греп `baseline, bookCap := m.committed`); списание попытки = разница счётчика с baseline; счётчик ниже baseline = подменённая БД, сеттлится в ноль с WARN — сами цифры в лог не идут (D39.84; `platform/internal/runs/reconcile.go`, греп `the book's meter reads below`).
7.**Терминальный вердикт ОПЕРАТОРА — путь холда мимо settle (добавлено 22.08, P8-FIX/D39.154).** Прогон, чей движок не ответит уже никогда, закрывает человек: `tmplatformctl run abandon --run <id> --reason <текст> [--release-hold]`. Команда ОТКАЗЫВАЕТ, если попытка ещё называет юнит или несёт базовую линию траты — над живым движком прогон не закрывают. Холд возвращается ЦЕЛИКОМ в обоих случаях (к abandon допускается только попытка, не дошедшая до движка, значит она ничего не потратила), а флаг решает лишь КОГДА — сразу или на ближайшем свипе. ⚠ **С пака P11 (`PD-385`) ВЕТВЕЙ ДВЕ, и абзац выше — только ЖИВАЯ** (испр. ревизией 02.09; образец — секция «Строка PHASE = settling» в `platform/deploy/README.md`). У прогона, чья попытка уже КОНЧИЛАСЬ, команда идёт второй ветвью: отказ «юнит/базовая линия» к ней НЕ применяется, к abandon допускается попытка, которая ДОШЛА до движка и ПОТРАТИЛА, холд всё равно возвращается ЦЕЛИКОМ по каждой осиротевшей попытке, а флаг `--release-hold` там ИГНОРИРУЕТСЯ; единственная защита — порог `abandonAfter` неудач расчёта (отказ `ErrSettlementNotStuck`). ⚠ **Ветвление идёт по `runs.finished_at`, а НЕ по наличию осиротевшей попытки** — ратифицированная оговорка `PD-418`. ⚠ **И вторая ветвь НЕ лечит живую:** прогон с намертво заблокированной расплатой уходит в ЖИВУЮ ветвь и закрыт отказом по юниту до сих пор — `PD-424` стоит `open` именно на этой половине.
8. **Settle по фигуре движка.** `reconcile.settle` (`platform/internal/runs/reconcile.go`, греп `func (s *Service) settle(` — ⚠ номера строк в файлы живой зоны не ставим, пока её сессия работает) читает committed из status; `credits.Settle` одной транзакцией закрывает резервацию, возвращает холд и списывает spent; **spent > холда — капится холдом** с явной пометкой «capped at the hold; the engine reported …» (`platform/internal/pgstore/credits.go`, греп `capped at the hold; the engine reported`), перерасход движка счётом не абсорбируется.
9. **SpendBound (PD-159).** Отложенный settle читает пожизненный счётчик КНИГИ и без границы оплатил бы работу прогона-преемника (замер: $2.10 за прогон ценой $0.10, и преемник платил снова). Граница = наименьший baseline попыток той же книги, стартовавших ПОЗЖЕ: `committed = min(счётчик сейчас, SpendBound)` (`platform/internal/runs/reconcile.go`, греп `bound != nil`; `platform/internal/pgstore/runs.go`, греп `func (s *Store) SpendBound(`).
10. **Открытое — эскроу/intent (строка 136 бэклога, `docs/PROGRESS.md`, греп `Write-ahead intent`):** write-ahead intent + состояние `uncertain`-эскроу + `closing`; холд НЕ освобождается по выходу юнита (сигнал обгоняет денежные события); spend не постится без свидетельства провайдера; расход сверх холда — полной суммой, превышение явной строкой убытка. Стройка по research/25, «скоро». ⚠ **«Строка 103» — ФАНТОМ, снят 31.08:** такой строки в бэклоге нет (закрыта D39.131), а эмиттер шва ПОСТРОЕН с обеих сторон — платформа тейлит `events.jsonl` и принимает кадр `spend` (`platform/internal/ingest/events.go`, греп `TypeSpend Type`). Денежной слепоты это НЕ снимает по построению: поток событий — только свежесть. Платформа между спавном и settle слепа — защита держится холдом и потолком движка (§1).
## 4. Фронт (зона `frontend/`)
Максимум шкалы — `Balance` КАК ЕСТЬ (D39.115 п.2а); `Reserved` — справочная величина, `ReadAccount` отдаёт обе раздельно (`platform/internal/pgstore/credits.go`, греп `func (s *Store) ReadAccount(`). Шкала — в ГЛАВАХ: `CeilingBounds` — «in CHAPTERS. The conversion to money lives on the platform and is not exposed here in any form» (`docs/architecture/14-api-contract/openapi.yaml`, греп `The conversion to money lives on the platform`); клиенту запрещено клампить повторно; `max_chapters: 0` = состояние «исчерпано» вместо шкалы. Суммы на экран не идут: `Usage` несёт ТРИ обязательных члена — `state` (ok/low/exhausted), `remaining_percent` и `halt_reason` — и ни окон, ни сумм (`docs/architecture/14-api-contract/openapi.yaml`, греп `required: [state, remaining_percent, halt_reason]`); продуктовый носитель — ПТ-35 (`docs/product-requirements.md:63`=`ПТ-35 | Лимиты «как в Claude Code»`): стоп по потолку = `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; остаток — страница лимитов (S4+), слово владельца В-3. ⚠ **К-13 ЗАКРЫТ** (D39.132 п.2а: `null` на проводе; пере-суждён ревью 28 §4 №9 и НЕ опрокинут — с уходом дневной оси различие «свой потолок / нет кредита» перестало быть продуктовым).
## 5. Открытые дыры и их носители
| Дыра | Носитель |
|---|---|
| **Строка 166** — движковая поверхность оценки $/глава; $0.03 — временная мера беты (П-10 зоны) | `docs/PROGRESS.md`, греп `Движковая поверхность оценки` |
| **Строка 78** — леджер = нижняя граница (биллящийся decode-fail мимо request_log, ≤13% захода на ре-пробе) | `docs/PROGRESS.md`, греп `Леджер денег = НИЖНЯЯ граница` |
| **Строка 136** — эскроу/intent (см. §3 п.10) | `docs/PROGRESS.md`, греп `Write-ahead intent` |
| *(добавка сборщика)* **PD-157 (minor)**`day_usd` книги флагом не перекрывается, платформой не виден и не задаётся; в `status --json` дневной фигуры нет | `platform/docs/DEFECT_REGISTER.md`, греп `PD-157` |
## 6. Карта носителей темы
| Вопрос | Где живёт |
|---|---|
| Словарь терминов | `docs/glossary.md:55`=`**Деньги платформы:**` |
| Управляемый потолок: решение и форма | D39.110 (+поправка D39.112 п.3: п.1 — пересказ, не цитата) |
| Максимум шкалы = Balance как есть | D39.115 п.2а; эррата — шапка D-лога, `docs/architecture/05-decisions-log.md:4`=`Balance КАК ЕСТЬ` |
| Семантика `--ceiling-usd` | D39.122 п.2в; `backend/cmd/tmctl/invocation.go:142`=`the book USD ceiling in force for THIS RUN ONLY`, `backend/cmd/tmctl/main.go:260`=`--ceiling-usd is NOT a per-run budget` |
| Формула аргумента потолка (без reserved) · ставка · settle-формы | D39.123 п.2б/г/д; PD-158/PD-159/PD-144 в реестре |
| Код денег платформы | `platform/internal/pgstore/credits.go` · `pgstore/runs.go` · `runs/spawn.go` · `runs/reconcile.go` · `pricing/pricing.go` |
| Ратификации, на которых стоит этот док | **D39.179** (пере-пин цен 16.08, ×4.47; норма «стабилен чистый ФАЙЛ» — п.4) · **D39.181** (закон раскрытия: признак объёмного стопа едет числом) · **D39.182** (пак «деньги и честность»: план банк-ролей режется до первого вызова; порог согласия без контура банк-ролей) |
| Код денег движка | `backend/internal/store/ledger.go` · `store/store.go` · `pipeline/stagerun.go` · `pipeline/status.go` · `config/book.go` · `pipeline/rebill.go` (согласие на пере-оплату) · `pipeline/terminologist.go` (книго-широкие бюджеты банк-ролей) · `pipeline/volume.go` (объёмный потолок) |
| Дефекты денег | `platform/docs/DEFECT_REGISTER.md` (PD-104·113·144·157·158·159) |
| Бэклог-строки | `docs/PROGRESS.md` строки 78 · 136 · 137 · 166 (135 и 165 закрыты D39.131) |
| Контракт наружу | `14-api-contract/openapi.yaml` (`CeilingBounds`, `Usage`); ПТ-35 |
| Запрет денег на экране | D39.84; D39.100/К-8; ПТ-35 |
---
## ⚠ ОТКРЫТАЯ РАЗВИЛКА 04.09: КНИГУ НЕЛЬЗЯ ДОЧИТАТЬ — разбор трёх независимых чтений, решение за владельцем
> **Статус: НЕ РАТИФИЦИРОВАНО.** Это разбор, а не контракт: ниже нет ни одного решения, только сходящаяся
> фактура и названная развилка. Носитель дефекта — `PD-440`/`PD-441` в регистре платформы. Собрано по
> прямому указанию владельца «посоветуйся с зонами и со старшей моделью»: читали независимо зона движка,
> зона платформы и внешний рецензент другой модели; расхождения между ними названы, а не сглажены.
**ЧТО НАБЛЮДАЛОСЬ.** Первый живой ПЛАТНЫЙ прогон книги через API (04.09) остановился, не продвинув книгу
ни на юнит, и воспроизвёлся дважды. Прогон умирает за 1015 секунд, списание не двигается.
**ПОЧЕМУ — цепь, проверенная тремя чтениями независимо.** Запас нового прогона на НОВЫЕ траты равен
`chaptersLeft × ставка` (`platform/internal/runs/spawn.go`, греп `func (m meter) bookCap`; ставка
`DefaultPerChapter = 30_000` микро-USD). Резерв ОДНОГО редакторского вызова — worst case по `maxTokens`
(`backend/internal/pipeline/stagerun.go`, греп `func (r *Runner) callEstimateUSD`), замеренный отказ в
ряду `PD-440``denied estimate=$0.069828`. Отказ резервации трактуется как **авария всей книги**:
`runWave` зовёт `cancel()` производного контекста на ПЕРВОЙ ошибке любого рода, и уже допущенные
параллельные вызовы гасятся.
**ТРИ УТВЕРЖДЕНИЯ, СОШЕДШИЕСЯ У ВСЕХ ТРЁХ ЧТЕНИЙ:**
1. **Форма потолка неверна, а не только его размер.** Запас сжимается вместе с остатком книги, стоимость
неделимого шага — нет. Ставку можно поднять, стену это отодвинет, но не уберёт. Правильная форма —
`max(главы × ставка, шаг)`, а не `главы × ставка`.
2. **Резервацию убирать нельзя.** Она — единственный сериализованный контроль и единственное, что держит
обещание «не потратить больше, чем дано». Отраслевой стандарт тот же (см. прайор-арт ниже).
3. **Менять надо ТРАКТОВКУ ОТКАЗА**, а не наличие резервации: «не начинаем новое» вместо «гасим всё».
**ЧИСЛА, В КОТОРЫХ ЧТЕНИЯ РАСХОДЯТСЯ — и оба верны на своём числе воркеров.** Зона платформы: стена
стоит везде, где `chaptersLeft × ставка < workers × шаг`, при боевых четырёх воркерах это
`chaptersLeft ≤ 9` — недостижим не хвост, а **последние девять глав любой книги**. Внешний рецензент:
даже при ОДНОМ воркере `2 × $0.03 = $0.06 < $0.07` одного вызова, то есть **последние две главы
недостижимы в принципе**, независимо от параллельности. Второе — инвариантный пол, первое — сегодняшняя
конфигурация.
**Предсказание зоны платформы, ЗАМЕР ЗАКАЗАН, на момент записи НЕ подтверждён:** книга из ≤9 глав может
не переводиться вообще никогда. Проверяется за $0 на пятиглавой книге. До подтверждения — предсказание.
**ЧЕТВЁРТОЕ, ЧЕГО НЕ БЫЛО В ИСХОДНОМ ДИАГНОЗЕ (зона движка).** Резерв завышен ПО ПОСТРОЕНИЮ: оценка
считается по потолку выдачи. Под нынешним поведением он никогда не превращается в факт — его отпускают,
и следующее решение о допуске принимается против тех же завышенных чисел. Под «не начинаем новое»
соседи доседают по ФАКТИЧЕСКОЙ цене и освобождают разницу. Это само по себе может быть разницей между
«встала» и «дошла».
**ПЯТОЕ — ПРЕЦЕДЕНТ ВНУТРИ ЭТОГО ЖЕ ПАКЕТА, и он решает спор о направлении.** Объёмный потолок уже
построен с нужной семантикой и САМ противопоставляет себя денежному:
`backend/internal/pipeline/volume.go`, греп `THE STOP IS A COMPLETION, NOT A HALT` — «денежный потолок
останавливает прогон ПОСРЕДИ работы, которую он собирался сделать». Там же названа причина, по которой
допуск не сделан счётчиком у воркеров: «воркер, которому отказали в слоте, был бы вынужден бросить свой
элемент навсегда» — то есть ровно тот дефект, который денежный путь и имеет. Плюс три места уже
деградируют по `errReserveCeiling` вместо аборта (`escalation.go`, `repair.go`, `terminologist.go`);
не деградирует только главная волна.
**ШЕСТОЕ — ДЕФЕКТ, ПЕРЕЖИВАЮЩИЙ ЛЮБУЮ ПОЧИНКУ РАЗМЕРА И ФОРМЫ (зона платформы).** Стена МОЛЧАЛИВА:
прогон, вставший мгновенно, неотличим на всех поверхностях от работавшего — одинаковый статус, одинаковая
причина паузы, одинаковая полоса (наблюдение H16 зонного журнала, два прогона: $0.081 и $0). Недобрать
можно при любом потолке; сказать об этом сегодня нечем.
**ВЕС — ЖИВУЧЕСТЬ, НЕ ДЕНЬГИ.** Мёртвые прогоны рассчитались в ноль, леджер сходится тремя источниками
до микродоллара. Пользователь теряет ВРЕМЯ. ⚠ Отдельно и с другим весом — `PD-441`: на пути отмены есть
расход, который, ЕСЛИ он был, невидим для леджера по построению. Доказуемая форма именно такая; «деньги
потрачены» НЕ измерено. Одна движковая попытка — до трёх запросов к провайдеру (`backend/internal/llm/httpllm.go`,
греп `for att := 0; att < profile.MaxAttempts`, дефолт 3) под ОДНИМ резервом и ОДНОЙ строкой `request_log`:
след не просто беден, он агрегирован и не различает «не ушло ничего» от «ушло трижды».
**ПРАЙОР-АРТ (внешний рецензент, ссылки — в отчёте сессии).** LiteLLM делает буквально наше:
worst-case-резерв → отказ до отправки → замена фактической ценой; их известные баги — про ОБХОД бюджета
при параллельных запросах, то есть отраслевая беда противоположна нашей. Ближайший по смыслу стандарт —
Diameter Credit-Control (RFC 4006/8506) с `Final-Unit-Indication`: «это последний грант, доработай его и
не начинай нового» — ровно та семантика, которой нам не хватает. Kubernetes `ResourceQuota`: отказ — это
недопуск НОВОГО, уже созданное квота не трогает. Slurm `GraceTime`: слив вместо убийства. Карточная
пре-авторизация: холд с запасом, списание по факту. ⚠ Про биллинг при обрыве: OpenRouter документирует,
что для непотоковых запросов отмена клиентом биллинг НЕ останавливает — это факт о ЕГО платформе, для
наших провайдеров вопрос обязан быть закрыт вендор-докой по правилу `experiments/00-provider-quirks.md`,
а не рассуждением.
**РАЗВИЛКА ВЛАДЕЛЬЦА — ОДНА, И ЕГО ПРЕДЛОЖЕНИЕ ЕЁ РЕШАЕТ, А НЕ ОБХОДИТ.** Предложение владельца
(«называть примерную цену и останавливаться, когда деньги кончились») означает, что потолком становится
БАЛАНС. Тогда холд берёт весь баланс вперёд, и вторая книга не стартует, пока не рассчитается первая.
Это ровно та развилка, которую код уже держит открытой и помечает вопросом к владельцу:
`platform/internal/pricing/pricing.go`, греп `which fraction is a product question`. ⚠ Отдельный выбор
того же разговора: платят вызовом за ЮНИТ, а человек видит и выбирает ГЛАВЫ, и ставка за главу сама
признаёт слабость (греп `chapters differ in length`); `units_total` известен платформе до всякой оплаты.
**ЧТО СЧИТАЕТСЯ ЗАКРЫТЫМ И БОЛЬШЕ НЕ ОБСУЖДАЕТСЯ:** уже проданные прогоны смену ставки переживают без
миграции — продолжение оценивается по тому, за что прогон был ПРОДАН, бюджет читается обратно из холда
первой попытки, пин `TestARestartHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince`.
**СЕДЬМОЕ — ЕДИНИЦА УЧЁТА НЕ СОВПАДАЕТ С ЕДИНИЦЕЙ БИЛЛИНГА, и это бьёт не в отчётность, а в ЦЕЛОСТНОСТЬ
потолка** (найдено зоной движка чтением под вопрос, проверено оркестратором по строкам). Класс
`BilledDecodeError` заведён ровно для того, чтобы оплаченный-но-нечитаемый вызов стал ВИДЕН: рантайм
консервативно доседает его по оценке. Комментарий рядом сам называет цену ретрая —
`backend/internal/llm/httpllm.go`, греп `each retry is a new billed`, — и по этой причине УСЕЧЁННЫЙ ответ
сделан терминальным. Но битое тело осталось ретраябельным, а `retryLoop` при успешном ретрае возвращает
ответ и предыдущую ошибку ВЫБРАСЫВАЕТ (греп `if err == nil {` в `retryLoop`). Тогда рантайм видит один
успешный ответ, доседает его `Usage`, и оплаченный 2xx перед ним не попадает НИКУДА — ни в списание, ни
отдельной строкой лога. **Механизм, построенный чтобы сделать оплаченный вызов видимым, обходится ровно
на том пути, где всё прошло хорошо.** Следствие для потолка: допуск выдан один раз под одну оценку; если
под ней прошло два оплаченных ответа, `Reserve` об этом не узнает никогда — он сравнивает с потолком
сумму, в которой невидимого запроса нет. Потолок не «пробивается громко», он считает не то.
**Границы:** структурность доказана чтением; ЧАСТОТА пути НЕ измерена и, вероятно, мала — владельцу
эти две вещи давать раздельно. Более слабый родственник — сетевой обрыв/таймаут (там же комментарий про
`mid-generation RST that re-bills the call`): вход тот же, но «оборвалось на середине ⇒ оплачено» уже
вывод о провайдере, а не утверждение кода.
**ВЕНДОРСКИЙ ВОПРОС — ДВОЙНОЙ, и вторая половина конкретнее первой:** (1) платит ли провайдер за запрос,
оборванный КЛИЕНТОМ после начала генерации; (2) сколько раз он берёт деньги, когда мы ретраим ПОСЛЕ
оплаченного 2xx. Второй случай не гипотетический — он уже назван оплаченным в нашем собственном коде.
Оба закрываются вендор-докой по правилу `experiments/00-provider-quirks.md`, а не рассуждением.
**ГРАНИЦА ИНСТРУМЕНТА, ЗАМЕРЕННАЯ ЭТИМ ЖЕ ЗАХОДОМ.** Каталог мутаций (141 запись, 136 красных, ноль
переживших) доказывает ПОВЕДЕНИЕ и молчит о том, что код УТВЕРЖДАЕТ о себе. Круг 3 нашёл четыре
сверх-утверждения — и **ни одно не нашла посадка**; обе сегодняшние денежные находки получены чтением под
вопрос. Рабочая форма вопроса, выведенная зоной движка: не «правда ли то, что здесь написано», а **«что
должно было бы сломаться, если это правда, и ломается ли оно»**.