hraness

a credits ledger agents can spend against: double-entry for compute

holds, captures, and one transaction per mutation

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

A prepaid credit is a liability. When a product sells a credit that an agent or a user will later spend, it owes a future service, and the ledger that tracks that debt has to survive the two things that break naive implementations: a retried request that must not charge twice, and a spend that must be recorded in the same instant as the thing it paid for. The clean way to get both is double-entry bookkeeping in a single transaction. Every mutation produces two equal-and-opposite postings, so the ledger can never be half-written and the balance can never drift from the record.

The Sup product’s credits system implements exactly that. Credits are integer half-units, a post costs two, a comment costs one, and every purchase, debit, reversal, or reinstatement is one transaction that writes two postings against two accounts. What follows is the shape of that ledger and the disciplines that keep it honest.

integer units, no floats

Credits are counted in half-credit units and stored as integers, because fractional money is where floating-point bugs live. The constants in lib/credits.ts are explicit:

export const CREDIT_UNITS_PER_CREDIT = 2 as const;
export const POST_COST_UNITS = 2 as const;
export const COMMENT_COST_UNITS = 1 as const;

A post is one credit (2 units), a comment is half a credit (1 unit), and the formatter converts units back to a display string without ever doing float math: formatCreditUnits divides the integer and appends .5 only when the remainder is odd. Nothing in the system ever holds a 1.5 in a variable. That choice costs a little vocabulary and buys the property that every balance, delta, and cost is exactly representable.

two accounts, two postings

A wallet is not a balance; it is a pair of accounts whose sum is always zero. creditAccounts holds one available account and one contra account per wallet, each with a balanceUnits and a revision. A valid state requires the two to mirror each other (available.balanceUnits === -contra.balanceUnits) and to move together (available.revision === contra.revision). A purchase grant adds to available and subtracts the same amount from contra; a debit subtracts from available and adds to contra. The invariant is the accounting identity: the wallet’s net position is always zero, and the spendable part is whatever is in available.

On top of that sits the hard rule: every mutation is one ledger transaction that produces exactly two equal-and-opposite postings. creditTransactions records the transaction: its kind, units, the available delta, the balance after, an immutable requestSha256 digest of the request, a wallet-scoped idempotencyKey, and a typed reference to what caused it. creditPostings records the two account-level effects, each pointing back to the transaction and stamped with the account revision it produced.

The entry kinds are a closed union: purchase_grant, purchase_reinstatement, post_debit, comment_debit, purchase_reversal. The transition function in creditLedgerDomain.ts validates the input state before it writes. A malformed account is a rejected result, an unbalanced state is unbalanced_state, a revision mismatch is unpaired_revisions, and a debit that exceeds the available balance is insufficient_funds, never a negative balance.

The detail that makes it atomic with the product: a post or comment debit happens in the same Convex mutation that inserts the content and its first revision. The write that creates the thing and the write that charges for it are one transaction, so there is no state where the post exists but the credit was not taken, or the credit was taken but the post does not exist.

idempotency is a ledger key

Retries are free only because the key is real. Every transaction carries a wallet-scoped idempotencyKey with a by_wallet_and_idempotency unique index, plus a requestSha256 digest of the exact request. A replay with the same key and the same digest returns the stored outcome; the same key with a different digest is an idempotency conflict, not a dedup hint: the key was spent on a different request and the store refuses to pretend otherwise. creditPostings is indexed by_transaction and by_account_and_revision, so each posting is reconstructible and each account’s revision history is checkable.

The typed reference is what keeps the ledger honest about why a mutation happened. A purchase reference carries the purchase’s public ID; a post or comment reference carries the client request key; a reversal or reinstatement carries the adverse case and the provider object that caused it. The ledger can therefore answer “where did this credit come from” and “what did this debit pay for” without a separate audit table.

the ledger schema

The tables in convex/schema.ts are worth reading closely because the boundaries are declared, not implied. creditWallets is one row per suiteAccountId with a status and an optional review reason. creditAccounts is two rows per wallet (kind: "available" and kind: "contra") indexed by_wallet_and_kind and by_suite_account_and_kind. creditTransactions carries:

  • idempotencyKey and requestSha256: the dedup boundary,
  • kind and units and availableDeltaUnits: what happened,
  • availableBalanceAfterUnits: the materialized result,
  • reference: the typed cause, a union over purchase, post, comment, reversal, and reinstatement,
  • suiteAccountId and walletId: ownership.

creditPostings carries accountId, accountKind, accountRevision, balanceAfterUnits, deltaUnits, transactionId, and walletId, indexed by_transaction, by_account_and_revision, and by_wallet_and_created. The indexes are the contract: a wallet’s history is a bounded indexed read, a transaction’s two postings are a by_transaction lookup, and an account’s revision chain is a by_account_and_revision walk.

Notice which fields the browser never sees. The client reads a balance and a bounded history (api.credits.getBalance, api.credits.listHistory with a limit) and writes a purchase intent or a post. It never receives an OAuth token, a Stripe secret, a provider object ID, routing metadata, webhook evidence, a request hash, or an idempotency key. The request key is generated client-side so a retry can be keyed, but the digest and the ledger are server-side; the browser can ask to retry, it cannot claim the retry is identical.

holds and captures

Sup’s debits are immediate (content and charge commit together) but the reserve-then-capture shape shows up where spend is decoupled from delivery. In the same monorepo, PeopleBlade’s enrichment pipeline reserves one credit in a short-lived hold, claims exactly one durable run, spends only on completion, stores the evidence and output atomically with the spend, and releases the hold when the run finishes. The hold is what lets an agent attempt work without paying for an attempt that never lands, and the capture is what keeps a crashed run from holding a reservation forever: the hold is bounded and the spend is gated on completion evidence.

That is the general form of “holds, captures, and one transaction per mutation”: the hold is a temporary reservation with a deadline, the capture is the real posting pair written when the effect is confirmed, and the release is the path that undoes the hold when the effect does not happen. All three are ledger operations, none is a separate balance field.

The distinction between a hold and a balance is worth stating plainly because it is the part naive credit systems get wrong. A balance says “you have N”; a hold says “N is spoken for pending an outcome.” Only the hold can express “this might or might not happen” without either charging for a failure or letting a concurrent spend reuse the same units. When the outcome lands, the capture converts the reservation into the real two-posting mutation (the same post_debit-shaped write the synchronous path uses) so downstream reads never have to know the spend was deferred.

the adverse side

A purchased credit is not final until the payment is final, and the ledger models that. A lost dispute produces a purchase_reversal, at most once; a later favorable outcome produces a purchase_reinstatement, at most once; and a reversal that would overdraw the available balance leaves the wallet in payment_review (frozen for a human) rather than letting the ledger go negative. Entering wallet review schedules exact-ownership expiration for every still-open Checkout Session so a half-finished purchase cannot complete under a frozen wallet.

The purchase side is a separate state machine from the ledger, and the separation is the point. A creditPurchaseIntent is reserved, a creditCheckoutOperation freezes the idempotency key and request digest, a Stripe Checkout is created, and only the signed webhook, after the Session and PaymentIntent are re-retrieved and validated against environment, scope, Price, amount, currency, and metadata, produces the purchase_grant posting. A browser return from Checkout is display state; it never grants.

That is the deeper reason for the double-entry structure. A single-column balance can only go wrong silently; a two-account ledger has to stay balanced, so the states that need a human (a reversal overdraft, a mismatched revision, a provider event that does not reconcile) are states the ledger refuses to represent. The honest outcomes are the only ones the schema can hold.

what it costs

The honest price is that every mutation is two writes plus a transaction record, and every spend has to be planned against an available balance that is checked inside the mutation. Debits reject insufficient funds rather than overdraw, which means a fast agent can hit the wall mid-task and has to be told, not silently allowed to continue. The ledger is also product-local (Sup credits buy Sup posts and comments, and the wallet is keyed by suiteAccountId), so a credit in one product is not a credit in another, which is the correct boundary and also the thing you have to explain to a user who expects a shared balance.

What it buys is that the money question has a deterministic answer. Every unit is accounted for in two places that must agree, every change is a transaction with a typed cause and an idempotency key, and every adverse path (refund, dispute, reversal, ambiguous provider response) has a modeled outcome instead of a runtime surprise. For a product whose whole point is that an agent can spend against a prepaid balance, “the ledger can never be half-written” is not a nicety; it is the product.

sources

  • The monorepo behind this site (projects): Sup’s lib/credits.ts, convex/creditLedgerDomain.ts, convex/creditLedger.ts, convex/credits.ts, and convex/schema.ts.
  • PeopleBlade (peopleblade.com): the reserve-then-capture hold pattern for agent-driven compute spend.
  • Accounts (account.hraness.com): the suite_account_id ownership key and entitlement evidence the wallet is bound to.

keep reading: free for subscribers

the rest of this lesson is free. add your email once and every subscriber lesson on this site stays unlocked.

already subscribed? enter the same email to unlock.