Skip to content

The token ledger & the money path

Deployed

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.

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 at base_expires_at.
    • extra_tokens — purchased or promo credit, which never expires.

    Reads use lazy-zero: base_tokens is treated as 0 whenever NOW() > 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: the entry_type (deduction / refund / grant / renewal), the amount, the base_used / extra_used split, and the reason. Refunds also carry a related_entry_id pointing 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
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 --> [*]
  1. Estimate. The pipeline prices the request from the tool’s per-license pricing — cost_base, plus cost_increment in DYNAMIC cost mode. Dynamic pricing is quantity-aware: a tool’s usage_field names the input field holding the planned quantity n, so the reserve-time hold is cost_base + cost_increment × n instead of a flat base. See the tool pricing model.

  2. 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 as reservation_ledger_entry_id and reserved_token_amount, and the call id is stored as billing_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.

  3. 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 MarkTaskSettled sets settled_at WHERE 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:

core-mcp/internal/billing/billing.go
// actual > reserved: charge the shortfall
IdempotencyKey: idemKey + ":settle-extra" // reason "settlement-shortfall"
// actual < reserved: refund the surplus
idemKey + ":settle-refund" // reason "settlement-surplus"
// terminal failure: refund the whole reservation
idemKey + ":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 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.Sweep on a ticker (RECONCILE_INTERVAL, default 60s).
  • core-gateway-consumer’s run_reconciler on a matching 60s interval.

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 NULLand 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.