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

22 KiB
Raw Blame History

Путь денег — от гранта до 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).
  • --max-units — ОБЪЁМНЫЙ потолок прогона, ортогональный денежному (D39.165 §1б, принят D39.170). Ограничивает не деньги, а РАБОТУ: не больше N выходных ЮНИТОВ (гранулярность units_total манифеста — та же, в которой платформа продаёт главы) будет ОПЛАЧЕНО этим прогоном; юниты, отданные за $0 (резюм, ре-пин), ретраи и эскалации внутри юнита потолок не тратят. Принимает только translate. Остановка по объёму — ЗАВЕРШЕНИЕ (exit 0), не пауза: словарь кодов выхода не расширялся, различение живёт в отчёте прогона и в логе; отчёт разводит ДОСТАВКУ и ПЕРЕ-ДЕЛКУ. Носитель — backend/internal/pipeline/volume.go. ⚠ Проводка в платформу ГЕЙЧЕНА (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: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.Scaleaffordable = 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.165 §1, 28.08): реальная цена главы замерена по движковым леджерам — средняя $0.0115$0.0190, максимум одной главы $0.0375, то есть константа завышена в 1.62.6×, и «купить 10 глав» отдаёт движку $0.30, покупающий ~1626 глав («денежная ручка с подписью „главы"», PD-410). РЕШЕНО: цена — от ОБЪЁМА ИСХОДНИКА (носитель chapters.units_total, пишется интейком за $0), настоящий стоп — объёмный потолок В ДВИЖКЕ (см. §2). Константу по сегодняшним числам НЕ калибровать (D39.165 г) — гейт строка 202. Исторический провенанс: 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 книги на старте попытки (platform/internal/runs/spawn.go:82=baseline, bookCap := m.committed); списание попытки = разница счётчика с 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, греп func (s *Service) settle( — ⚠ номера строк в файлы живой зоны не ставим, пока её сессия работает) читает 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