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

63 lines
20 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` §«Деньги платформы» (`glossary.md:50`).
## 1. Канон одним абзацем
Деньги видимы с первого вызова: телеметрия, потолки, явное согласие на пере-оплату (CLAUDE.md, цель 5). Леджер без потолка запрещён — Р7: книга обязана нести хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250-251`, текст валидатора: «a ledger with no ceiling is forbidden, Р7»). Суммы на пользовательский экран НЕ выходят — запрет D39.84, подтверждён D39.100/К-8 («страница лимитов = СТАТУС, не суммы», D-лог `:196`) и держится в контракте (`14-api-contract/openapi.yaml:1152`: «No window, no `resets_at`, no sums»). Защита от перерасхода двойная и не зависит от событий: холд платформы делает кредит недоступным следующему прогону, а движок сам останавливается на потолке — «an overspend is impossible even while the platform is blind. The event stream is freshness only» (док-коммент `Hold`, `platform/internal/pgstore/credits.go:148-151`).
## 2. Деньги ДВИЖКА (зона `backend/`)
- **Леджер SQLite.** Таблица `spend`, две фигуры: `committed_usd` (потрачено, с сырым ответом провайдера) и `reserved_usd` (зарезервировано под летящий вызов). `Reserve` перед каждым платным вызовом сверяет `SUM(committed_usd + reserved_usd)` книги и дня с потолками (`backend/internal/store/ledger.go:44,58,63`; Р7 — коммент `:12`); `Settle` конвертирует резервацию в committed и персистит ответ (`ledger.go:162,199-210`). Суммарно по книге — `SpentUSD` (`ledger.go:314-320`).
- **Гейт потолков — ПЕР-ВЫЗОВНЫЙ, и был им до эмиттер-пака** (`Reserve` на каждый свежий attempt; прежняя формулировка «на границе юнита» была неверна по отношению к потолкам — опровергнута приёмкой D39.131). На границе юнита сидел РЕПЭЙР-суб-бюджет — ужесточён до пер-вызовного с ценой вызова (строка 135 закрыта D39.131); эскалационный кап хоп НЕ прицениваает — перелёт ≤1 хопа, задокументирован и запинен (диспозиция D39.131 п.2д, реопен — живой инцидент).
- **leftover-reserved зануляется write-open.** `store.Open` (путь записи, каждый `translate`) выполняет `recoverReservations` (`backend/internal/store/store.go:88`, функция `:218`: `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`) — файл владеется одним процессом, значит любой reserved на открытии принадлежит несеттлённому прогону. `OpenReadOnly` этого прохода намеренно НЕ делает (`store.go:95-104`) ⇒ **reserved из read-only `status` — всегда остаток мёртвого процесса** (несущий факт формулы PD-158, см. §3).
- **`--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:114`; ⚠-коммент `main.go:110`). Ратификация — D39.122 п.2(в): пересчёт «пользовательский прирост → абсолют» — обязанность ПЛАТФОРМЫ, вторая денежная ось не заводится. День-потолок флаг НЕ перекрывает (PD-157). Проводка внутри: `Ceilings.BookUSD` в `Reserve` и именование сработавшего потолка в ошибке — `backend/internal/pipeline/stagerun.go:479-480,505-511` (стоп = `errReserveCeiling`).
- **Потолки — wiring, не семантика:** `Ceilings` намеренно исключены из `BriefHash` («Wiring fields (paths, ceilings, db) deliberately excluded», `book.go:262-265`, канон `:267+`) ⇒ смена потолка не двигает ни снапшот, ни ре-билл (D39.110 п.2б).
- **`status --json` отдаёт фигуры платформе:** `committed_usd`, `reserved_usd`, `book_ceiling_usd`, `ceiling_pct` = 100·(committed+reserved)/book_ceiling (`backend/internal/pipeline/status.go:155-158`). Дневной фигуры в status нет (PD-157).
- **Леджер = НИЖНЯЯ граница** (строка 78 бэклога, `docs/PROGRESS.md:41`): «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).
## 3. Деньги ПЛАТФОРМЫ по шагам (зона `platform/`)
1. **Грант.** `Grant` — строка леджера `kind='grant'`, идемпотентная по `(аккаунт, source, source_id)`: `on conflict (user_id, source, source_id) do nothing` (`platform/internal/pgstore/credits.go:38-49,357-362`). Фри-тир — тот же механизм в ТОЙ ЖЕ транзакции, что создание аккаунта (`pgstore/identity.go:86-88,153-155`; ⚠ **дефолт `SignupGrantMicroUSD` — НОЛЬ** (`platform/internal/config/config.go:262`), а не «5 × 1_000_000», как утверждала прежняя редакция: предложение «на бете дефолт в НОЛЬ» (D39.110 п.3) ИСПОЛНЕНО, `PD-104` в регистре стоит `fixed(P7)`. ⚠ Испр. аудитом доков 28.08 (D39.167) — ложная сумма стояла в ЕДИНСТВЕННОМ месте, куда идут за деньгами.
2. **Выбор потолка — шкала в ГЛАВАХ** (D39.110: от 1 до максимума, ноль не выбираем; потолок принадлежит ПРОГОНУ, в `book.yaml` не пишется). **Максимум = Balance КАК ЕСТЬ** (D39.115 п.2а; эррата в шапке D-лога `:4`): `Hold` — дебет в момент взятия, баланс уже не содержит открытых холдов, повторное вычитание вдвое укорачивает шкалу. Реализация: `pricing.Model.Scale``affordable = balance / PerChapter`, кламп по остатку глав, дефолт = верх шкалы (ратифицировано D39.123 п.2е; компромисс «весь баланс под одну книгу» принят осознанно) — `platform/internal/pricing/pricing.go:75-98`.
3. **Ставка $0.03/глава — конфиг-константа платформы:** `DefaultPerChapter = 30_000` micro-USD (`pricing.go:33`), переопределяется `PerChapterMicroUSD` (`config.go:90-93,194,229`). Провенанс сверен D39.123 п.2(г): exp08 v2 $0.0219/глава → ревизия D30.4 +1525% → округление ВВЕРХ (низкая ставка опаснее высокой — прогон встанет на потолке посреди книги; `pricing.go:19-33`). Временная мера беты — честная оценка движковая (строка 166).
4. **Холд прироста ДО спавна.** `holdTx` пишет отрицательную строку `('hold', -amount)` и тем же знаком двигает кэш баланса (`credits.go:163-206,375-385`); нехватка — `ErrInsufficientCredit`. Холд + прогон + попытка + очередь — одна транзакция (`pgstore/runs.go:104` StartRun, `:338` рестарт; форма D39.123 п.2д).
5. **Аргумент движку: `bookCap = committed + прирост×ставка`, БЕЗ reserved — PD-158, дословно из кода: `return m.committed + increment`** (`platform/internal/runs/spawn.go:195-196`). Обе фигуры читаются ОДНИМ вызовом `status --json` перед стартом (`bookMeter`, `spawn.go:206-223`); отсутствие любой из них = отказ спавна («absent is not zero», PD-40). Почему без reserved: write-open движка занулит leftover до первой резервации, значит цифра из read-only status к моменту сравнения уже мертва; ратифицированная ранее формула с reserved переплачивала запасом ровно на leftover сверх холда — исполнено обеими формулами против гейта (D39.123 п.2б, поправка к D39.122(в)). Аргумент ≠ холд: он больше холда на committed (глоссарий `:50`). Фактически ушедшее значение хранится в `run_attempts.ceiling_arg_micro_usd` (PD-144, миграция 00011); потолок уходит argv-шаблоном `{{usd}}` (`config.go` `CeilingArg`, `spawn.go:132-150`); строка 145 закрыта D39.122.
6. **Baseline и списание.** `baseline = committed` книги на старте попытки (`spawn.go:74`); списание попытки = разница счётчика с baseline; счётчик ниже baseline = подменённая БД, сеттлится в ноль с WARN — сами цифры в лог не идут (D39.84; `reconcile.go:308-316`).
7.**Терминальный вердикт ОПЕРАТОРА — путь холда мимо settle (добавлено 22.08, P8-FIX/D39.154).** Прогон, чей движок не ответит уже никогда, закрывает человек: `tmplatformctl run abandon --run <id> --reason <текст> [--release-hold]`. Команда ОТКАЗЫВАЕТ, если попытка ещё называет юнит или несёт базовую линию траты — над живым движком прогон не закрывают. Холд возвращается ЦЕЛИКОМ в обоих случаях (к abandon допускается только попытка, не дошедшая до движка, значит она ничего не потратила), а флаг решает лишь КОГДА — сразу или на ближайшем свипе. До этого пака такого пути не было вовсе: холд чужого аккаунта висел, пока кто-нибудь не залезет в БД руками.
8. **Settle по фигуре движка.** `reconcile.settle` (`platform/internal/runs/reconcile.go:277+`) читает committed из status; `credits.Settle` одной транзакцией закрывает резервацию, возвращает холд и списывает spent; **spent > холда — капится холдом** с явной пометкой «capped at the hold; the engine reported …» (`credits.go:214-234`), перерасход движка счётом не абсорбируется.
9. **SpendBound (PD-159).** Отложенный settle читает пожизненный счётчик КНИГИ и без границы оплатил бы работу прогона-преемника (замер: $2.10 за прогон ценой $0.10, и преемник платил снова). Граница = наименьший baseline попыток той же книги, стартовавших ПОЗЖЕ: `committed = min(счётчик сейчас, SpendBound)` (`reconcile.go:293-307`; `pgstore/runs.go:268`).
10. **Открытое — эскроу/intent (строка 136, `docs/PROGRESS.md:85`):** write-ahead intent + состояние `uncertain`-эскроу + `closing`; холд НЕ освобождается по выходу юнита (сигнал обгоняет денежные события); spend не постится без свидетельства провайдера; расход сверх холда — полной суммой, превышение явной строкой убытка. Стройка по research/25, «скоро». До эмиттера (строка 103) платформа между спавном и settle слепа — защита держится холдом и потолком движка (§1).
## 4. Фронт (зона `frontend/`)
Максимум шкалы — `Balance` КАК ЕСТЬ (D39.115 п.2а); `Reserved` — справочная величина, `ReadAccount` отдаёт обе раздельно (`pgstore/credits.go:99`). Шкала — в ГЛАВАХ: `CeilingBounds` — «in CHAPTERS. The chapters → money conversion lives on the platform and is not exposed here in any form»; клиенту запрещено клампить повторно; `max_chapters: 0` = состояние «исчерпано» вместо шкалы (`openapi.yaml:1116-1147`). Суммы на экран не идут: `Usage` = `state` (ok/low/exhausted) + `remaining_percent`, без окон и сумм (`openapi.yaml:1149-1165`); продуктовый носитель — ПТ-35 (`docs/product-requirements.md:62`): стоп по потолку = `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; остаток — страница лимитов (S4+), слово владельца В-3, К-13 (`paused_reason` не различает «свой потолок» и «нет кредита»).
## 5. Открытые дыры и их носители
| Дыра | Носитель |
|---|---|
| ~~PD-113 (major)~~ **ЗАКРЫТ P6/D39.132** (испр. оркестратором 15.08): обе половины построены — событие `ceiling{scope}` + exit 4 (движок, D39.131) и потребитель (платформа, П-15): потолочный стоп = `paused` двумя независимыми каналами, причина пишется одним оператором со статусом и перечитывается после drain. ⚠ **Хвост «открытых major в регистре платформы 0» СНЯТ 22.08 — он протух:****прежняя редакция называла открытым мажором `PD-370` — он ЗАКРЫТ** (обе половины: зонная 22.08, контрактная минором 0.5.0 D39.161; регистр переведён 28.08 аудитом доков D39.167), и держать счёт чужого регистра здесь — лишний носитель, который протухает молча; счёт печатает `python3 docs/scripts/counts.py` | `platform/docs/DEFECT_REGISTER.md` (эра P6), D39.132 |
| ~~Строка 165~~ **ЗАКРЫТА D39.131** (испр. оркестратором 14.08): exit 4 = потолок · 5 = graceful stop · 1019 полоса отказов; потребительская половина построена P6 (D39.132) — шов закрыт с обеих сторон; ⚠ exit 13 (`schema_mismatch`) едет паком migrate, ратификация направления — D39.132 | D39.131, D39.132 |
| **Строка 166** — движковая поверхность оценки $/глава; $0.03 — временная мера беты (П-10 зоны) | `docs/PROGRESS.md:114` |
| ~~**PD-104**~~ **ЗАКРЫТА** (`fixed(P7)`; дефолт гранта в коде — ноль, испр. 28.08 D39.167) — фри-тир: док↔код закрыто, продуктовая половина (ноль на бете · агрегатный потолок · счётчик) на владельце | `DEFECT_REGISTER.md:115`, D39.110 п.3 |
| **Строка 78** — леджер = нижняя граница (биллящийся decode-fail мимо request_log, ≤13% захода на ре-пробе) | `docs/PROGRESS.md:41` |
| **Строка 136** — эскроу/intent (см. §3 п.10) | `docs/PROGRESS.md:85` |
| *(добавка сборщика)* **PD-157 (minor)**`day_usd` книги флагом не перекрывается, платформой не виден и не задаётся; в `status --json` дневной фигуры нет | `DEFECT_REGISTER.md:168` |
## 6. Карта носителей темы
| Вопрос | Где живёт |
|---|---|
| Словарь терминов | `docs/glossary.md:50` §«Деньги платформы» |
| Управляемый потолок: решение и форма | D39.110 (+поправка D39.112 п.3: п.1 — пересказ, не цитата) |
| Максимум шкалы = Balance как есть | D39.115 п.2а; эррата — шапка D-лога `:4` |
| Семантика `--ceiling-usd` | D39.122 п.2в; `tmctl/invocation.go:114`, `main.go:110` |
| Формула аргумента потолка (без 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` |
| Код денег движка | `backend/internal/store/ledger.go` · `store/store.go` · `pipeline/stagerun.go` · `pipeline/status.go` · `config/book.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 |