The token ledger & the money path
Soundverse treats money as a first-class, auditable primitive. Every generation moves tokens exactly once along a three-step path — estimate → reserve → settle — and every movement is written to an append-only ledger that is engineered so it cannot be silently corrupted or double-applied. This page explains the ledger, the money path, and the one property everything else leans on: idempotency.
The rule that makes it all safe: tool workers never touch tokens. A worker only reports
raw usage via ctx.report_usage(); the caller that priced the request (core-mcp on the
agent path, core-gateway-consumer on the consumer path) is the only thing that reserves and
settles. See Add a tool worker.
Two tables: balances and the ledger
Section titled “Two tables: balances and the ledger”Money lives in two tables under the billing schema, both defined in
billing/tokens/v1/schema.sql.
-
billing.token_balances— the current spendable amount, split into two buckets:base_tokens— the cycle allotment, which expires atbase_expires_at.extra_tokens— purchased or promo credit, which never expires.
Reads use lazy-zero:
base_tokensis treated as0wheneverNOW() > base_expires_at, so no cron job is needed to expire a cycle. Spends consume base first, then extra (v_base_used := LEAST(effective_base, amount)). -
billing.token_ledger— an append-only audit trail of every movement. Each row carries its own proof: theentry_type(deduction/refund/grant/renewal), theamount, thebase_used/extra_usedsplit, and thereason. Refunds also carry arelated_entry_idpointing back at the reservation entry they reverse.
Five defenses that make the ledger untrustable-by-mistake
Section titled “Five defenses that make the ledger untrustable-by-mistake”| Defense | How | Why it matters |
|---|---|---|
| Append-only | BEFORE UPDATE OR DELETE trigger trg_token_ledger_no_mutation calls billing.reject_ledger_mutation(), which raises |
History cannot be rewritten — corrections are compensating entries, never edits |
| Idempotency | Partial unique index uq_token_ledger_idempotency ON (idempotency_key) WHERE idempotency_key IS NOT NULL |
The same key can be written at most once |
| Sum invariant | chk_ledger_split_matches requires base_used + extra_used = amount |
A row that doesn’t add up is physically impossible |
| Non-negative | chk_ledger_amount_positive, non-negative checks on both buckets |
Corrupt magnitudes are rejected at write time |
| Minimal locking | Deduct / Refund / Grant / Renew are each one atomic plpgsql function that takes FOR UPDATE on one token_balances row (never the ledger) |
No contention on the ledger; no partial writes |
The money path
Section titled “The money path”stateDiagram-v2 [*] --> Estimate Estimate --> Reserve: price by license + cost_mode Reserve --> Rejected: status not OK Reserve --> Queued: DeductTokens reason=reservation Queued --> Processing: worker claims task Processing --> Completed Processing --> Failed Completed --> Settle: price actual vs reserved Settle --> ChargeShortfall: actual gt reserved Settle --> RefundSurplus: actual lt reserved Settle --> Settled: actual eq reserved Failed --> RefundAll ChargeShortfall --> Settled: suffix settle-extra RefundSurplus --> Settled: suffix settle-refund RefundAll --> Settled: suffix refund-all Settled --> [*]: MarkTaskSettled Rejected --> [*]
-
Estimate. The pipeline prices the request from the tool’s per-license pricing —
cost_base, pluscost_incrementinDYNAMICcost mode. Dynamic pricing is quantity-aware: a tool’susage_fieldnames the input field holding the planned quantityn, so the reserve-time hold iscost_base + cost_increment × ninstead of a flat base. See the tool pricing model. -
Reserve. The estimate is taken immediately via
DeductTokens(reason="reservation"), keyed on the call id. The resulting ledger entry id and held amount are persisted onto the task asreservation_ledger_entry_idandreserved_token_amount, and the call id is stored asbilling_idempotency_key— the stable seed for every later settle key. A non-OK status (e.g.INSUFFICIENT_FUNDS) rejects the tool call cleanly, before anything is queued. A free tool (amount <= 0) short-circuits to OK and writes no ledger row. -
Settle. When the task terminates, the caller prices the actual usage and reconciles against the reservation:
actual > reserved→ deduct the shortfall (reason="settlement-shortfall").actual < reserved→ refund the surplus (reason="settlement-surplus").actual == reserved→ no-op.- Failure → refund the whole reservation (
reason="failed-generation-refund").
Then
MarkTaskSettledsetssettled_atWHERE settled_at IS NULL, so marking twice is a no-op.
Why concurrent settlement can’t double-charge
Section titled “Why concurrent settlement can’t double-charge”Every settle action derives its idempotency key from the task’s persisted
billing_idempotency_key by appending a stable suffix. The three suffixes and the price
math are reimplemented in two languages and are currently byte-identical:
// actual > reserved: charge the shortfallIdempotencyKey: idemKey + ":settle-extra" // reason "settlement-shortfall"// actual < reserved: refund the surplusidemKey + ":settle-refund" // reason "settlement-surplus"// terminal failure: refund the whole reservationidemKey + ":refund-all" // reason "failed-generation-refund"# actual > reserved: charge the shortfallidempotency_key=idem_key + ":settle-extra" # reason "settlement-shortfall"# actual < reserved: refund the surplusidem_key + ":settle-refund" # reason "settlement-surplus"# terminal failure: refund the whole reservationidem_key + ":refund-all" # reason "failed-generation-refund"Because each suffix is a pure function of the task, the in-request settle path, the
core-mcp reconciler, and the gateway reconciler all compute the same key for the
same task. The first writer wins via the ledger’s unique index; every other writer gets
ALREADY_APPLIED and the original entry. The reserve step itself uses the bare call id (no
suffix), so it can never collide with a settle.
The reconciler backstop
Section titled “The reconciler backstop”The in-request settle only runs if the caller is still connected when the task terminates. If a client disconnects first, an at-least-once backstop sweeps in. Both callers run one:
- core-mcp’s
reconciler.Sweepon a ticker (RECONCILE_INTERVAL, default60s). - core-gateway-consumer’s
run_reconcileron a matching60sinterval.
Each sweep calls ListUnsettledTasks, which returns only terminal tasks that still need
money moved — status IN ('completed','failed') AND reserved_token_amount > 0 AND settled_at IS NULL — and only those whose completed_at < NOW() - INTERVAL '2 minutes'. That grace
window gives the in-request settle first crack, so the backstop only fires on genuinely
orphaned reservations. Each swept task runs through the exact same settle / refund_all +
MarkTaskSettled sequence, keyed identically, so a reconciler settling a task the in-request
path already handled is a harmless no-op.
Failures on the money path are never swallowed silently: a failed in-request settle
increments a settlement_failure_total counter, is attached to the trace, logged loudly, and
deliberately not marked settled — so the reconciler retries it on the next sweep. See
Billing reconciliation.
Related
Section titled “Related”- Tool pricing model — how the estimate is computed
- Add pricing to a tool — the
NotFoundtrap and explicit FREE - Task queue on Postgres — the queue the reservation rides on
- Data model overview — where these tables sit in the schema
- The agent + MCP path — the second billing pipeline
- Billing reconciliation — the sweep in operation
- Data schema catalog — where the authoritative DDL lives