Money & billing
Credits, reserve/settle/refund, async settlement, and payment rails.
Research snapshot of quill-router at main @ 778120494 (2026-10-19). Read-only investigation; no production systems touched.
Source paths are relative to the repo root; src/trusted_router/ is abbreviated tr/ in tables.
AGENTS.md: "
reserve/settle/refundand anything touching credits are the highest-risk code in the repo. Changes there need a differential or conformance test, never review-by-eye."
1. Overview (the 5-minute version)
Unit of account. All money is integer microdollars (1 USD = 1,000,000 µ$). See tr/money.py. Floats exist only for response compatibility (microdollars_to_float). Token cost is (tokens * rate_micro_per_million + 500_000) // 1_000_000 (round half-up), tr/money.py:57.
Customer price. Upstream provider cost × 1.055 (STANDARD_PRICE_MARKUP_BASIS_POINTS = 550, tr/pricing.py:126), ceiling-rounded, with a floor of $0.01 per million tokens. Prices come from a committed snapshot (tr/data/openrouter_snapshot.json) that an hourly price-refresh bot (.github/workflows/refresh-prices.yml, scripts/pricing/refresh.py) regenerates and auto-commits to main, which auto-deploys. Big price jumps are held unless they appear in tr/pricing_approvals.py.
The ledger ("typed book"). Since 2026-06-26 the only authoritative money book is three native Spanner tables. The JSON-blob book in tr_entities was retired (docs/design/retire-json-credit-ledger.md, billing-dual-system-deletion-handoff.md):
| Table | Key | Money columns |
|---|---|---|
tr_credit_balance |
(workspace_id, shard) |
total_credits, total_usage, reserved, in_debt, billing_pause_causes |
tr_key_limit |
(key_hash, shard) |
limit_micro (NULL = uncapped), usage, byok_usage, reserved, include_byok |
tr_reservation |
reservation_id |
credit_reserved_micro, key_reserved_micro, actual_micro, settled, expires_at, terminal_at, idempotency scope |
Available balance on a shard = total_credits - total_usage - reserved. A hot workspace can be split across N shards. Each shard is an independent sub-budget, so the global cap holds by construction (docs/design/credit-row-sharding-handoff.md). The JSON credit entity is now metadata only: auto-refill config and Stripe customer/payment-method ids.
Per-request lifecycle. The attested enclave (quill-cloud-proxy) calls the control plane's internal routes:
1. POST /internal/gateway/authorize → authorize_atomic reserves an estimate with one conditional DML per row: UPDATE ... SET reserved = reserved + @est WHERE headroom >= @est AND NOT in_debt. Row-count 1 means accepted, 0 means 402. The reservation row is inserted with expires_at.
2. Optional POST /internal/gateway/heartbeat records started_at plus the usage delivered so far. The reaper can use this later.
3. POST /internal/gateway/settle (success) or /refund (failure) → _settle_gateway_authorization prices the actual usage, freezes the amount into a SettleOutboxRow, then finalizes:
- claim the reservation (UPDATE tr_reservation SET settled=true ... WHERE settled=false; first writer wins),
- mark the authorization settled, write generation/activity,
- release the exact holds and book actual (credit row first, then key row).
4. Reaper (reap_expired_reservations_result) reclaims holds that were never settled. It releases them free, or with Decision 70 snapshot booking it books the last heartbeat usage snapshot. It never frees a hold that a pending/dead settle-outbox row still intends to charge.
5. Settle-outbox drain (/internal/gateway/settle-outbox/drain, every 5 min) replays any intent whose inline finalize didn't land. It applies the frozen amount through a narrow primitive.
Why it's this complicated. Two failure directions: - Double charge. Prevented by the first-writer-wins reservation claim. N replays book at most once. - Lost charge. The request was served but the settle never landed, and the reaper released it free. Prevented by the durable settle outbox plus the reaper guard. Per the design, the reaper guard is the sole correctness mechanism for this direction. Drain timing only buys latency.
Async settlement (in flight, dormant). This is a multi-month program to move finalize off the response path. The enclave gets a 202 after one durable intent commit, and the drain books the charge. It is gated behind an enormous evidence apparatus: "shadow v2", "gates", "packs", "certificates", and a "frozen-main differential" proof_oracle. These prove that the change alters no money behavior before anyone flips it on. Production today: TR_ASYNC_SETTLE_ENABLED=false, TR_ASYNC_SETTLE_PROTECTION=false, TR_ASYNC_SETTLE_SHADOW_POLICY=shadow-v2 for one dedicated probe workspace (scripts/deploy/rollout.sh:659-664).
Money in. Payment rails (Stripe card/ACH/auto-refill, x402 stablecoin via Stripe, PayPal, Adyen (dark), Lightning (separate LightningRouter workers)) all funnel into one function: credit_workspace_typed_direct (tr/storage_gcp.py:3183). It is idempotent on a stripe_event marker entity keyed by a rail-specific event id, and it writes a trust "payment fact" in the same transaction.
Money out. Creator earnings from app markup and custom-model markup go to tr_earnings_balance and are paid via Routable ACH (dark, TR_ROUTABLE_ENABLED=false).
2. Invariants (memorize these)
| # | Invariant | Where enforced |
|---|---|---|
| I1 | All money is int microdollars. No floats in ledger math. Rounding is half-up per component (token cost) or ceiling (markup). |
tr/money.py, tr/pricing.py:134-170, tr/billing_snapshot.py (int64 overflow is an explicit error) |
| I2 | Per shard: total_usage + reserved <= total_credits at reserve time. The reserve is a single conditional UPDATE that takes the write lock directly, with no read→upgrade. This is the fix for the wound-wait deadlock. |
reserve_credit tr/storage_gcp_counter_dml.py:68; design docs/design/billing-typed-counters.md §2 |
| I3 | Shards partition the workspace budget, so global overspend at admission is impossible. Sum over shards of credits/usage/reserved gives the workspace totals. | docs/design/credit-row-sharding-handoff.md; tr/storage_gcp_credit_shards.py |
| I4 | First writer wins on tr_reservation.settled false→true. Only the claim winner touches counters. Replays are 0-row no-ops. |
claim_reservation tr/storage_gcp_counter_dml.py:781 |
| I5 | Release subtracts the exact recorded hold (reserved >= @hold guard). Row-count must be 1. A 0-row release must never be silently accepted, because it strands the hold and loses the charge. |
release_credit tr/storage_gcp_counter_dml.py:299 |
| I6 | Lock order: credit row before key row, everywhere (authorize, finalize, reaper, top-up). No cycle. | settle_atomic docstring tr/storage_gcp_authorize.py:958; tests/test_settle_lock_order.py |
| I7 | A settle can exceed its hold (actual > estimate). The overrun is booked anyway (test_settle_books_usage_always.py). If a shard goes negative it is covered from other shards, or every shard is marked in_debt while the signed sum is negative. Marked rows refuse reservations. Inflows repay negative rows first, lowest shard first. |
tr/credit_debt.py, tr/storage_gcp_credit_debt.py:177; TLA+ proofs/CreditDebt.tla |
| I8 | Reaper guard. The reaper never free-releases a reservation whose authorization has a tr_settle_outbox row in pending or dead. This is re-checked inside the read-write claim transaction. A scan-time filter alone is insufficient (MF2). Only a human-set release_approved re-permits a free release. |
tr/storage_gcp_authorize.py:1397-1440, _CLAIM_RESERVATION_GUARDED_SQL tr/storage_gcp_counter_dml.py:41 |
| I9 | Frozen amount wins (MF5). The drain books actual_cost_micro exactly as stored. It never reprices, even across deploys, catalog changes, or endpoint retirement. It also routes on the stored settle_origin (MF4). |
apply_frozen_settle tr/services/settle_outbox_apply.py:136 |
| I10 | Outbox PK is (authorization_id, intent_kind ∈ {settle, refund}). Enqueue is INSERT-as-claim (ALREADY_EXISTS), not an upsert. |
SpannerSettleOutbox.enqueue tr/storage_gcp_settle_outbox.py:500 |
| I11 | finalized=False is ambiguous. already_released_free on a row that intended a charge is an invariant violation → dead + alert, never "recovered". |
ApplyOutcome tr/services/settle_outbox_apply.py:78; design §3 |
| I12 | Analytics never destabilizes money. The benchmark analytics-outbox INSERT rides in the money commit only on the one-commit happy path. Every failure falls back to the two-commit settle, with the enqueue after commit and best-effort. | tr/storage_gcp_analytics_outbox.py docstring; AGENTS.md "Money code" |
| I13 | Payment credit is exactly-once per external payment. The stripe_event marker plus the trust payment fact (keyed by provider + PaymentIntent/capture id) are written in the same transaction as the total_credits bump. |
credit_workspace_typed_direct tr/storage_gcp.py:3183 |
| I14 | Refunds and chargebacks never silently debit customer credits. They become adverse trust events with recovery debt ("unrecovered principal"), absorbed from future inflows/releases, or an inbox entry for review. | record_adverse_trust_event tr/storage_gcp.py:3324; absorb_unrecovered_recovery_tx tr/storage_gcp_trust.py:282 |
| I15 | Flag-off byte identity. Every new money mechanism ships dormant. With the flag off, SQL, parameters, and RPC sequence must be byte-identical to before. This is proven by the frozen-main differential. | claim_reservation_statement docstring; tests/test_async_settle_proof_oracle.py |
| I16 | Async acceptance commits responsibility, never money. The async enqueue transaction must not claim, book, release, or touch credit/key rows. Its reservation predicate (settled=false, row-count exactly 1) prevents acknowledging an unbookable charge. |
tr/storage_gcp_async_settle.py:28; docs/design/async-settle-outbox-v1.md §4 |
| I17 | Standing auditor tripwire: typed.reserved == Σ open typed holds (both directions, shard-aware). It runs daily in CI. |
audit_typed_invariants tr/storage_gcp_counter_reconcile.py:242; .github/workflows/typed-audit.yml |
3. Reservation state machine
stateDiagram-v2
[*] --> Held: authorize_atomic - conditional reserve OK
[*] --> Rejected: row-count 0 (insufficient / in_debt / paused)
Rejected --> [*]
Held --> Held: heartbeat (started_at, delivered usage)
Held --> IntentDurable: settle/refund - outbox INSERT (two-commit path)
Held --> SettledCharged: one-commit settle (claim + book + intent done)
Held --> Refunded: refund claim wins (actual=0)
Held --> ReapedFree: reaper - expired, no pending/dead intent
Held --> ReapedSnapshot: reaper - expired, started + snapshot booking
IntentDurable --> SettledCharged: inline finalize claims reservation
IntentDurable --> SettledCharged: drain apply_frozen_settle (frozen amount)
IntentDurable --> Frozen: drain gives up (dead) - reaper blocked
Frozen --> SettledCharged: human applies manual charge
Frozen --> ReapedFree: human sets release_approved
SettledCharged --> [*]: terminal_at armed (30-day retention)
Refunded --> [*]
ReapedFree --> [*]
ReapedSnapshot --> [*]Diagram source
stateDiagram-v2
[*] --> Held: authorize_atomic - conditional reserve OK
[*] --> Rejected: row-count 0 (insufficient / in_debt / paused)
Rejected --> [*]
Held --> Held: heartbeat (started_at, delivered usage)
Held --> IntentDurable: settle/refund - outbox INSERT (two-commit path)
Held --> SettledCharged: one-commit settle (claim + book + intent done)
Held --> Refunded: refund claim wins (actual=0)
Held --> ReapedFree: reaper - expired, no pending/dead intent
Held --> ReapedSnapshot: reaper - expired, started + snapshot booking
IntentDurable --> SettledCharged: inline finalize claims reservation
IntentDurable --> SettledCharged: drain apply_frozen_settle (frozen amount)
IntentDurable --> Frozen: drain gives up (dead) - reaper blocked
Frozen --> SettledCharged: human applies manual charge
Frozen --> ReapedFree: human sets release_approved
SettledCharged --> [*]: terminal_at armed (30-day retention)
Refunded --> [*]
ReapedFree --> [*]
ReapedSnapshot --> [*]Notes:
- "Claim" is the settled false→true transition. Every arrow into a terminal state goes through claim_reservation, so concurrent inline, drain, and reaper attempts resolve to exactly one winner.
- A settle intent and a refund intent can coexist (different PKs). The first reservation claimant wins. The loser drains to already_settled_with_charge, plus a low-priority review flag if a refund lost to a charge.
- Fast-admission pilot (TR_FASTPATH_PILOT_ENABLED=true for two workspaces): the hold lives in a lease on the Go "front door" service, not in tr_reservation. The reservation row "holds nothing". Settlement goes through _settle_leased_authorization / tr/services/fastpath_settle.py. TLA+ specs LeaseLifecycle, TerminalOrder, AuditorCommit, KeyCapFence are planned. CreditDebt and AutoRefillHandoff are implemented.
4. Settle sequence: one-commit happy path vs two-commit fallback
sequenceDiagram
participant E as Enclave (quill-cloud-proxy)
participant R as Router /internal/gateway/settle
participant S as Spanner
participant D as Outbox drain (5 min)
E->>R: POST settle (authorization_id, actual usage)
R->>S: read gateway authorization
R->>R: price usage, build frozen SettleOutboxRow
alt one-commit happy path (typed, outbox available)
R->>S: read reservation
R->>S: Batch DML: claim + auth finalize + INSERT intent(status=done) + activity + benchmark + credit release + key release
S-->>R: commit OK
R-->>E: 200 settled
else any deviation: declined, abort, 5s budget, unknown outcome
R->>S: rollback
R->>S: commit 1: INSERT intent(status=pending, next_attempt +60s)
Note over R,S: intent durable here, ALREADY_EXISTS finds a done row if the one-commit actually landed
R->>S: commit 2: typed_finalize_atomic (claim, book, mark done in same txn)
alt finalize OK
S-->>R: settled
R-->>E: 200 settled
else finalize fails / crash
R-->>E: intent_durable (or 5xx)
D->>S: claim due rows (sharded sparse index, lease 300s)
D->>S: apply_frozen_settle: claim + book frozen actual_cost_micro
D->>S: mark done / dead / park (lease-fenced)
end
endDiagram source
sequenceDiagram
participant E as Enclave (quill-cloud-proxy)
participant R as Router /internal/gateway/settle
participant S as Spanner
participant D as Outbox drain (5 min)
E->>R: POST settle (authorization_id, actual usage)
R->>S: read gateway authorization
R->>R: price usage, build frozen SettleOutboxRow
alt one-commit happy path (typed, outbox available)
R->>S: read reservation
R->>S: Batch DML: claim + auth finalize + INSERT intent(status=done) + activity + benchmark + credit release + key release
S-->>R: commit OK
R-->>E: 200 settled
else any deviation: declined, abort, 5s budget, unknown outcome
R->>S: rollback
R->>S: commit 1: INSERT intent(status=pending, next_attempt +60s)
Note over R,S: intent durable here, ALREADY_EXISTS finds a done row if the one-commit actually landed
R->>S: commit 2: typed_finalize_atomic (claim, book, mark done in same txn)
alt finalize OK
S-->>R: settled
R-->>E: 200 settled
else finalize fails / crash
R-->>E: intent_durable (or 5xx)
D->>S: claim due rows (sharded sparse index, lease 300s)
D->>S: apply_frozen_settle: claim + book frozen actual_cost_micro
D->>S: mark done / dead / park (lease-fenced)
end
endKey facts (docs/design/durable-settle-outbox.md §7, tr/routes/internal/gateway.py:4413-4600):
- Per authorize+settle round trip, one-commit takes read-write commits from 4 → 2.
- If the one-commit transaction actually landed but reported failure, the fallback is still safe. The enqueue hits ALREADY_EXISTS on a done row, finalize claims 0 rows, and nothing is charged twice.
- With settle_outbox_enabled=True (production) and typed request records, a failed enqueue returns 503 ("Settlement durability is temporarily unavailable"). The router does not proceed without durability.
- Honest scope: durability starts at the intent commit. A crash before that still depends on enclave redelivery, which is a bounded in-memory retry channel (1,024 jobs, 6 attempts) and does not survive enclave loss.
5. Payment ingestion flow (Stripe webhook → credits)
flowchart TD
A["Stripe POST /internal/stripe/webhook"] --> B{"stripe-signature valid?"}
B -- "no / missing" --> X["400"]
B -- "secret unset in prod" --> X2["503"]
B -- "yes" --> C["to plain dict (_to_dict_recursive)"]
C --> D{"adverse event? (charge.refunded, refund.updated, dispute.*)"}
D -- "yes" --> T["record_adverse_trust_event: recovery debt or trust inbox"]
D -- "no" --> E{"event type"}
E -- "checkout.session.completed / async_payment_succeeded" --> F{"mode=setup?"}
F -- "yes" --> F1["save stripe customer (no credit)"]
F -- "no" --> G{"payment_status == paid?"}
G -- "no" --> G1["payment_pending (no credit)"]
G -- "yes" --> H["principal = credit_amount_microdollars (fee excluded, validated vs amount_total)"]
E -- "payment_intent.succeeded + auto_refill=true" --> H2["refill principal (validated vs PI amount)"]
E -- "payment_intent.succeeded + x402" --> H3["credit_x402_payment_intent"]
E -- "setup_intent.succeeded" --> F1
H --> I["credit_workspace_typed_direct(event_id)"]
H2 --> I
H3 --> I
I --> J{"stripe_event marker exists?"}
J -- "yes" --> K["replay: credited=false"]
J -- "no" --> L["insert trust payment fact (provider + PaymentIntent)"]
L --> M["total_credits += amount (debt rules: repay negative shards first)"]
M --> N["insert stripe_event marker, drain matching trust inbox"]
N --> O["commit, then record_credit_purchase analytics"]Diagram source
flowchart TD
A["Stripe POST /internal/stripe/webhook"] --> B{"stripe-signature valid?"}
B -- "no / missing" --> X["400"]
B -- "secret unset in prod" --> X2["503"]
B -- "yes" --> C["to plain dict (_to_dict_recursive)"]
C --> D{"adverse event? (charge.refunded, refund.updated, dispute.*)"}
D -- "yes" --> T["record_adverse_trust_event: recovery debt or trust inbox"]
D -- "no" --> E{"event type"}
E -- "checkout.session.completed / async_payment_succeeded" --> F{"mode=setup?"}
F -- "yes" --> F1["save stripe customer (no credit)"]
F -- "no" --> G{"payment_status == paid?"}
G -- "no" --> G1["payment_pending (no credit)"]
G -- "yes" --> H["principal = credit_amount_microdollars (fee excluded, validated vs amount_total)"]
E -- "payment_intent.succeeded + auto_refill=true" --> H2["refill principal (validated vs PI amount)"]
E -- "payment_intent.succeeded + x402" --> H3["credit_x402_payment_intent"]
E -- "setup_intent.succeeded" --> F1
H --> I["credit_workspace_typed_direct(event_id)"]
H2 --> I
H3 --> I
I --> J{"stripe_event marker exists?"}
J -- "yes" --> K["replay: credited=false"]
J -- "no" --> L["insert trust payment fact (provider + PaymentIntent)"]
L --> M["total_credits += amount (debt rules: repay negative shards first)"]
M --> N["insert stripe_event marker, drain matching trust inbox"]
N --> O["commit, then record_credit_purchase analytics"]Card events key the idempotency marker on the Stripe event id. ACH keys it on stripe_checkout:{payment_intent} so that completed and async_payment_succeeded dedupe (_checkout_credit_event_id, tr/routes/internal/webhook.py:545). One 2026-09-04 fix matters here: if a backfill scan wrote the trust fact before the webhook arrived, the webhook still credits exactly once. Before the fix it answered credited:false, Stripe stopped retrying, and the payment was zeroed (tr/storage_gcp.py:3221-3250).
6. Key modules
6.1 Ledger, reserve, settle, refund, reaper
| Module | Path:line | Role |
|---|---|---|
| Money primitives | tr/money.py:57 (token_cost_microdollars), :69 (dollars_to_microdollars) |
Integer µ$ math, display formatting, checkout limits ($10k Stripe max, $10 PayPal min, $0.30 signup credit) |
| Customer pricing | tr/pricing.py:126 |
5.5% markup, floor, tier selection (select_price_tier :74, last tier is the fallback), cache pricing |
| Counter DML | tr/storage_gcp_counter_dml.py:68 reserve_credit · :299 release_credit · :472 reserve_key · :622 release_key · :781 claim_reservation · :810 claim_reservation_statement |
The single-statement conditional DML primitives |
| Authorize | tr/storage_gcp_authorize.py:468 authorize_atomic |
One DML-only txn: credit hold (shard candidates), key hold, reservation + authorization insert, idempotency replay |
| Typed finalize | tr/storage_gcp_authorize.py:1693 typed_finalize_atomic |
Claim → auth finalize → generation/activity → credit release → key release. Optional one-commit settle_outbox_intent, payouts |
| Claim-gated settle | tr/storage_gcp_authorize.py:958 settle_atomic |
Narrow settle/refund used by the drain and reaper, with in-txn outbox guard |
| Reaper | tr/storage_gcp_authorize.py:1224 reap_expired_reservations_result, :1397 _finalize_reaped_reservation_atomic |
Guarded reclaim. Decision 70 snapshot booking (TR_REAP_SNAPSHOT_BOOKING_ENABLED=true in prod) |
| Payout txs | tr/storage_gcp_authorize.py:2162/2233/2290 |
User-model, app-markup, and custom-model-markup payouts into tr_credit_movement + tr_earnings_balance inside the settle txn (INSERT OR IGNORE idempotent) |
| Debt rules | tr/credit_debt.py:57-125, tr/storage_gcp_credit_debt.py:177 |
Cover / mark / repay (pure math + Spanner application) |
| Shards | tr/storage_gcp_credit_shards.py, tr/storage_gcp_credit_rebalance.py, scripts/expand_billing_shards.py, .github/workflows/reshard-billing-workspace.yml |
Shard choice, lazy rebalancing of fragmented sub-budgets |
| Live balance | tr/typed_balance.py:28 live_credit_summary |
Read path for display/API (typed snapshot) |
| Auditor / repair | tr/storage_gcp_counter_reconcile.py:242 audit_typed_invariants, :548 repair_typed_reserved; scripts/audit_typed_counters.py |
Standing money tripwire |
| Gateway routes | tr/routes/internal/gateway.py:2492 authorize · :2500 heartbeat · :2552 settle · :2561 refund · :2584 settle-outbox/drain · :2628 home-settlement/drain · :2648 deferred/reap |
Enclave-facing internal API |
| Settle handler | tr/routes/internal/gateway.py:3776 _settle_gateway_authorization; one-commit attempt :4437; two-commit enqueue :4484 |
Pricing, frozen intent, commit-path choice |
| Config flags | tr/config.py:1042 reap snapshot · :1059-1066 settle outbox · :967-979 async settle · :1023 fastpath |
Defaults off. Production values live in scripts/deploy/rollout.sh ENV_VARS |
6.2 Durable settle outbox
| Module | Path:line | Role |
|---|---|---|
| Native outbox store | tr/storage_gcp_settle_outbox.py:491 SpannerSettleOutbox (enqueue :500, claim :627, mark :681, park :811, has_intent :1104, purge_done :1131) |
INSERT-as-claim, lease-fenced state machine, 16 generated queue_shards, sparse tr_settle_outbox_due_v2 index |
| Statement builders | tr/storage_gcp_settle_outbox.py:178 intent_insert_statements, :289 resolved_intent_statements, :362 mark_done_unleased_tx |
Shared by one-commit, two-commit, and async enqueue |
| Frozen apply | tr/services/settle_outbox_apply.py:78 ApplyOutcome, :136 apply_frozen_settle |
8+ outcome contract (settled_now, already_settled_with_charge, already_released_free, reservation_missing, park_typed_unavailable, invalid_row, reaped_snapshot, …) |
| Drain | tr/services/settle_outbox_drain.py:72 drain_settle_outbox, :195 _resolve_row |
Budget 240 s, lease 300 s, limit ≤500 |
| Fast worker (dormant) | tr/services/settle_outbox_worker.py |
Warm worker for async; settle_outbox_fast_drain_enabled=false |
| Auto-refill handoff | tr/services/auto_refill.py:58, tr/services/auto_refill_outbox_drain.py |
Refill attached to the settle-outbox row; TLA+ AutoRefillHandoff |
| Analytics outbox (best effort) | tr/storage_gcp_analytics_outbox.py, tr/storage_gcp_operational_analytics_outbox.py |
Benchmark/activity delivery toward ClickHouse; 16 hash shards to avoid a monotonic-key hotspot |
6.3 Async settlement and shadow (dormant)
| Module | Role |
|---|---|
tr/billing_snapshot.py:436 build_snapshot, :477 evaluate, :520 validate_envelope, :369 canonical_hash |
Frozen billing v1 contract. Pure, strict-JSON, canonical-hash pricing evaluator. A Go twin lives in quill-cloud-proxy. Shared 759-case fixture tests/fixtures/async_settlement/billing_v1.json. Doc: docs/async-settlement-billing-v1.md |
tr/async_settle_ticket.py |
Detached signed settlement tickets (separate-purpose JWS) |
tr/services/async_settle.py |
Authorize-time projection + fail-closed bounded admission cache |
tr/services/async_settle_handler.py |
X-TR-Settlement-Mode: async-v1 boundary: parse → verify ticket → price → intent → 202, or sync_required |
tr/storage_gcp_async_settle.py:28,37 |
Async enqueue: intent INSERT + retention clears + async_reservation_admission_statement (reservation must be open; row-count exactly 1) |
tr/async_settle_fence.py |
Capability so only the frozen apply primitive can book an async-tagged intent. Legacy claims get a NOT EXISTS fence (claim_reservation_statement) |
tr/async_settle_shadow_*.py (v1, ~12 modules) |
Shadow observation lifecycle: cohort, compare, evidence, journal, health, projection, wire |
tr/async_settle_shadow_v2_*.py (~95 modules: gate_*, pack_*, collect_*, a1_*, a2_*, certificate, export*, native*, activation_*) |
Shadow v2 offline evidence/gate machinery (see §7) |
tr/storage_gcp_async_settle_shadow.py |
Shadow-only complete-key transactions; no money writes |
6.4 Payments and payouts
| Module | Path:line |
|---|---|
| Stripe webhook | tr/routes/internal/webhook.py:44 |
| Single credit entry point | tr/storage_gcp.py:3183 credit_workspace_typed_direct; memory twin tr/storage.py:2129; protocol tr/store_protocol.py:762 |
| Adverse events (refund/dispute) | tr/storage_gcp.py:3324; tr/storage_gcp_trust.py |
| Stripe checkout / fees / Radar | tr/services/stripe_billing.py, tr/services/stripe_fees.py (card 2.9%+30¢, ACH 0.8% capped at $5, stablecoin 1.5%: tr/config.py:722-731), tr/services/stripe_radar.py |
| Billing routes | tr/routes/billing.py:80 checkout · :143 PayPal capture · :168/211 x402 fund/settle · :248 portal · :289 coinbase (deprecated) |
| Operator grants | scripts/grant_credit.py; payment owner lookup scripts/inspect_payment_owner.py (the only sanctioned way to attribute a Stripe id; see AGENTS.md) |
7. What the async-settle vocabulary means
| Term | Meaning | Why it exists |
|---|---|---|
| Async settle (v1) | The enclave opts in (X-TR-Settlement-Mode: async-v1). The router verifies a signed ticket, prices with frozen billing v1, commits one transaction containing the pending outbox intent, and returns 202 with settlement_status=pending. The existing drain books it later. |
Settle p50 was 103–703 ms depending on region (europe-west4 is worst). Async takes waiting for finalize off the response path. It reuses the outbox, frozen amount, claim gate, lease fence, and reaper guard: "no second settlement queue". |
| Admission | Whether a request may use async: eligible cohort (typed, local, ordinary Credits; OpenAI/Anthropic; chat/responses; exact usage), paid tier 2/3, workspace pending-sum ≤ cap ($5 pilot, $25/$100), fleet drain p95 ≤ 5 s. | Exposure control. The doc states plainly that this is a lag guard, not a linearizable cap. |
| Protection | async_settle_protection. Fences legacy and sync writers so they cannot rewrite or race an async-tagged intent. Must be on before admission and turned off last. |
Rolling-deploy safety (tr/config.py Settings docstring gives the disable order). |
| Shadow | Run the async pricing/normalization path (Python + Go) on real opted-in traffic without it booking anything. Sync books; shadow compares. | A seven-day clean window of zero unexplained disagreements is an exit criterion before admission. |
| Shadow v2 | Amended evidence policy (2026-10-11, A1/A2/A3 amendments). It requires a closed partition T = A + B + C per terminal attempt bucket, where A = compared eligible, B = authenticated exclusion, C = unresolved/lost, and C must be 0. |
v1's gate could not certify a real fleet: missing health publisher, idle boots, recovered checkpoints counted as loss, historical boots summed as if concurrent. |
| Gates (G1–G11) | Executable PASS predicates, each with named gap literals (58 in total): policy/schema, deployment roster, authorize obligations, terminal partition, eligible comparisons, timer/health, fleet load (CPU < 45%), durability, resets, a 604,800 s (7-day) clock, external proofs. Implemented in async_settle_shadow_v2_gate_*.py / gates.py. Pilot tools: fleet-budgets-pre → fleet-budgets → shadow-serving → pre-flip → post-flip. |
One shared predicate implementation, so the report and the pilot can't disagree. Unevaluable means failure, never a skip. |
| Packs | async_settle_shadow_v2_pack_*: offline packaging of each day's original evidence (Monitoring pages, CI archives, trial records, budgets) into a bounded, hash-chained "daypack" with predecessors, so a gate can be evaluated over a reviewed, immutable input graph. |
Evidence must be the original bytes, finite and reviewable, not a re-derived summary. |
| Collect / export / native | Dormant read-only collectors (collect_*), offline original-byte export (export*), and native readers that do only finite PK-range reads with no retries. |
Gathering evidence must not create production load (cf. the September 6 CPU incident). |
| Certificate | async_settle_shadow_v2_certificate.py: a separate-purpose exclusion signature. It proves that a terminal attempt was authentically out of cohort (bucket B) without a price comparison. |
Lets the partition close without counting excluded traffic as loss, and without an unsigned "trust me" flag. |
| A1 / A2 / A3 | Design amendment rounds of shadow v2 (a1_*, a2_* modules). A1: health/obligations/close-result. A2: report/failure contract and digests. A3: live export and activation inputs. |
Each round answered adversarial review findings. |
| Frozen-main differential | tests/fakes/frozen_main/package.tar.gz is a byte-pinned tarball of the router source at a BASE commit, loaded under a separate import alias (frozen_main). Tests run the same authorize→settle→drain scenario through frozen code and live code against the fake Spanner, then assert identical responses, DB state, drain outcomes, SQL trace, and RPC counts. An execution guard rejects any accidental call into live code. |
Mechanically proves "flag-off = byte-identical money behavior" instead of relying on review. Inventory: 330 modules / 1,665 execution entries (docs/async-settle-frozen-main-inventory.md). Re-pin with scripts/async_settle/freeze_reference.py. |
proof_oracle |
pytest marker for those differentials (over an hour; sharded in CI). Skipped by default (pyproject.toml:77). |
Run explicitly when touching billing: uv run pytest -q -n 4 -m proof_oracle tests/test_async_settle_proof_oracle.py |
Mutations (tests/*_mutations.py) |
Hand-written mutants of the money code that the test suite must kill. | Shows a test actually guards the property, not just that it passes. |
| proofs/ | TLA+ specs model-checked by TLC in CI (./proofs/check.sh, proofs-guards.yml). Each claim must be broken by a listed mutant. Each guard has a swept table. |
Covers interleaving bugs that tests can't schedule (debt across shards, auto-refill handoff, lease lifecycle). |
8. Payment rails
| Rail | Direction | Status | Entry point | Idempotency key | Notes |
|---|---|---|---|---|---|
| Stripe Checkout (card) | In | Live | POST /billing/checkout → webhook checkout.session.completed |
Stripe event id | Fee passed through separately; principal is validated against amount_total |
| Stripe ACH | In | Live | Same, payment_method=ach |
stripe_checkout:{payment_intent} |
Async success/failure events; no auto-refill PM capture |
| Stripe auto-refill | In | Live | maybe_charge_after_settle off-session PI → payment_intent.succeeded (auto_refill=true) |
event id; refill attached to outbox row | Triggered after settle when balance < threshold |
| x402 stablecoin (USDC via Stripe) | In | Flagged (x402_enabled) |
/billing/x402/fund, /settle; webhook PI |
PaymentIntent | Refunds go to manual review. Design: docs/design/x402-stablecoin-funding.md |
| PayPal | In | Live | /billing/paypal/orders/{id}/capture, /internal/paypal/webhook |
paypal_capture:{capture_id} |
PENDING captures are never credited. Refunded-before-credit reconciliation: docs/paypal-inbox-reconciliation.md |
| Adyen | In | Dark (TR_ADYEN_ENABLED=false) |
/internal/adyen/webhook (HMAC) → credit_adyen_notification |
checkout reference (HMAC-bound) | Webhook verification is independent of the enable flag, so late payments still credit. Refunds/chargebacks go to manual review. docs/adyen-rollout.md |
| Lightning (LightningRouter) | In | Live, separate workers | /internal/lightning/credit → LightningCredits.credit |
lightning:{payment_hash} + immutable binding |
No BTC balance stored. LND / Lexe (SGX) backends. BTCPay is a dashboard only. docs/lightning-*.md |
| Coinbase | In | Deprecated | /credits/coinbase → deprecated() |
n/a | |
| Signup / operator grants | In | Live | credit_workspace_once, scripts/grant_credit.py |
grant event id | $0.30 starter credit, granted atomically at account creation |
| Cross-plane credit transfer | Move | Live | tr/credit_transfer.py state machine + storage_gcp_credit_transfer.py |
transfer id | Multi-cloud: "identity federates; credits do not". Transfers are explicit and conserve value |
| Federated deferred settlement | Move | Flagged | /internal/gateway/home-settlement/drain, deferred/reap |
outbox row | A peer plane records spend while home is unreachable and forwards it later |
| App / custom-model markup | Internal | Live | payout txs in typed_finalize_atomic |
app_markup_payout:{authorization_id} |
Owner gets 70% of markup (CUSTOM_MODEL_OWNER_SHARE_BASIS_POINTS=7000). Reconcile with scripts/reconcile_app_markup_payouts.py |
| Routable | Out | Dark (TR_ROUTABLE_ENABLED=false) |
tr/routes/payouts.py, /internal/routable/webhook |
payable external id | US ACH to identity-verified creators, debiting tr_earnings_balance. docs/routable-payouts.md |
9. How to safely change money code (checklist)
- Read first:
docs/design/durable-settle-outbox.md§3–§7,docs/design/billing-typed-counters.md, AGENTS.md "Money code", and the async-settle design docs if you touch settle or outbox. - Ship dormant. Put new behavior behind a default-off flag. Flag-off SQL, params, and RPC sequence must stay byte-identical. Production flags live only in
scripts/deploy/rollout.shENV_VARS. Hand-set Cloud Run env vars are overwritten. - Prove it, don't eyeball it. Add a differential, conformance, or mutation test:
-
tests/conformance/for Store-protocol behavior (memory, postgres, spanner-pg). Never weaken one. - Frozen-main oracle when touching settle, outbox, authorize, or billing:uv run pytest -q -n 4 -m proof_oracle tests/test_async_settle_proof_oracle.py. If your change is intended, re-pin viascripts/async_settle/freeze_reference.pyand regenerate the inventory. - Add a mutant (tests/*_mutations.py) for any new guard. Confirm that removing the guard fails a test. - Fake fidelity (MF6).
tests/fakes/spanner.pyis a substring SQL interpreter. Any predicate you add to an already-matched SQL string needs a matching fake change plus_require_pred, or the fake silently ignores it and your guard tests are theater. - Patch the class, not
STORE.monkeypatch.setattr(STORE, …)poisons later tests (issue #333). Run the full suite:uv run ruff check . && uv run mypy && uv run pytest -q. Never runruff format .. - Preserve the spine. Claim-first-writer-wins, exact-hold release with row-count == 1, credit-then-key lock order, the in-txn reaper guard, frozen amount and origin, INSERT-as-claim, and no new read→write upgrade on hot counter rows.
- Never touch analytics inside the money txn except the documented one-commit ride-along with full fallback.
- Interleavings? If the change is a protocol (leases, handoffs, multi-shard), extend or add a TLA+ spec in
proofs/with mutants and a guard table. CI runs./proofs/check.sh. - Rebase before trusting a failure. The hourly price bot moves
main. - Production safety. Never run raw or ad-hoc DML on billing tables. Use reviewed CLIs (
scripts/grant_credit.py,audit_typed_counters.py,ramp_typed_billing.py). Never full-scantr_entities.body. Usescripts/inspect_payment_owner.pyfor Stripe attribution. Verify with the typed audit after any mutation. - Review gate. Historical practice for billing PRs: an adversarial review (codex) before merge, no auto-merge armed in parallel, and owner (Joseph) sign-off for any flag flip that changes live enforcement.
- Hotspots. Any new Spanner index or key on a monotonic timestamp must lead with a hash shard (cf. the
tr_settle_outbox_due_v2incident,durable-settle-outbox.md§10.1). Don't "fix" latency alerts by loosening thresholds.
10. Open risks and sharp edges
- Pre-intent loss window. A crash between receiving settle and the intent commit relies on enclave redelivery. That is an in-memory channel and does not survive enclave loss. Enclave-side durability is a cross-repo change with no owner go yet (
durable-settle-outbox.md§9). - Overrun exposure. A settle above its hold books anyway and can push shards into debt (
in_debtmarks). Async adds pending exposure that the design admits has no C-only numerical worst-case bound. The admission predicate is a cached lag guard, not a hard cap (async-settle-outbox-v1.md§6). This is unresolved policy before any pilot. - Complexity of the shadow apparatus. About 95
shadow_v2modules plus about 130 async-settle test files, for a feature that is still off. It is a large maintenance surface, and very few people can reason about it. Its own docs call the production observations "gatekeeper-reported". Risk: evidence tooling drifts from the live code, or blocks unrelated changes through frozen pins and inventories. - Frozen-main re-pin churn. Any intended change to pinned modules (gateway, authorize, settle outbox, config, partner_billing, …) needs a re-freeze plus inventory regeneration. It is easy to forget, and the oracle is skipped by default locally.
- Dead outbox rows freeze holds indefinitely. They need a human runbook (manual charge, then
release_approved). Confirm alerting ondeadcount and age exists and is watched. - Snapshot booking is live (
TR_REAP_SNAPSHOT_BOOKING_ENABLED=true). The reaper now books money based on heartbeat-delivered usage, which is a newer billing behavior (Decision 70). Interactions with outbox intents and partial streams deserve scrutiny. - Fast-admission pilot (Go front door, leases) runs for real workspaces, yet 4 of its 6 TLA+ specs are still "planned". The hold lives outside Spanner on that path.
- Webhook handler shape.
tr/routes/internal/webhook.pyis a long if-chain over dicts. The 2026-05-23StripeObject.getregression shows SDK upgrades can silently break crediting. Synthetic chain tests are the safety net. - Refund/dispute handling is mostly manual for Adyen, x402, and PayPal edge cases. Recovery debt can stay unrecovered (alerts via
alert_unrecovered_principal). - The price bot auto-commits to
mainhourly and auto-deploys. A parser self-heal driven by an LLM is sandboxed and spike-gated, but a bad price ships straight into the frozen per-request cost. The settle outbox will faithfully book it (MF5). - Multiple fake backends (memory
storage.py, postgres, spanner-pg, fake Spanner). Behavior parity relies on the conformance suite plus_require_preddiscipline.