Skip to content

[design] The usage ledger records value, not charge — add payer attribution #346

Description

@serge-ivo

The confusion this exists to end

A delegation was refused with "the $50.00 daily spend limit has been hit". The user went to
platform.claude.com to see the $50 — and found nothing, because no money had been spent. The
engine had been running on their Claude subscription.

Chasing that took an hour and produced a wrong first answer from me, twice. The reason it was
hard is not a bug in one function. It is that the ledger has no column for who pays.

The model we have

ai_usage records tokens and one cost_micros, plus cost_source (migration 0080): 'reported'
when Claude Code computed the figure, NULL when we did.

That axis answers "whose arithmetic produced this number?" — a minor provenance detail. It was
then used everywhere as if it answered "is this number money?", which it does not, and which
nothing in the schema answers at all.

What the vendor documentation actually says

From Manage costs effectively (Anthropic, current):

Claude Code computes the dollar figure locally from token counts priced at standard list
rates
, so it doesn't reflect promotional pricing or contracted discounts and may differ from
your actual bill
. For authoritative billing, see the Usage page in the Claude Console.

Claude Max and Pro subscribers have usage included in their subscription, so the session cost
figure isn't relevant for billing purposes
.

So total_cost_usd is the same kind of object as our own estimate — tokens × list price. It is
not a measurement of money, in any auth mode. And on a subscription it corresponds to no charge
at all
.

Note also that subscription limits are not denominated in dollars: they are a rolling 5-hour
window and a weekly window, shared across Claude chat, Claude Code and Cowork. There is no
subscription number a dollar ceiling could ever be comparing against.

The model we should have

Three distinct facts, currently collapsed into one column:

Fact Always true? Have it?
Consumption — input / output / cache-read / cache-write tokens yes ✅ stored
Notional value — tokens × list price, "what this would cost on the API" yes, as an estimate cost_micros
Charge — money someone actually owes depends on the payer missing

The missing thing is not a better cost number. It is payer.

payer:
  byok-api      — the user's own provider API key. Real money, their provider account.
  subscription  — a Claude/ChatGPT subscription. No marginal charge; draws a plan allowance.
  platform      — PAGS pays (Workers AI, platform models).
  unknown       — we could not determine it. Say so; never guess.

provider (anthropic / openai / platform) is the vendor, not the payer, and cannot
substitute: the same vendor is reached both ways.

With payer present, everything downstream becomes correct by construction rather than by
special-case:

Determining the payer honestly

resolveEngineAuth (packages/browser-runner/src/coding/engine-auth.ts:63) already derives
api-key / subscription / machine-login from the merged spawn env, and authResolved already
reaches the cloud on the capture snapshot (workers/api/src/routes/coding.ts:734) and in
diagnostics (:1680). It is displayed and then discardedrecordEngineUsage
(workers/api/src/lib/usage.ts:147) never receives it and the ledger row has nowhere to put it.
Persisting it is most of the mechanical work.

The honest gap: machine-login does not mean "subscription". It means neither credential was
in the env and the CLI used whatever login it has stored — which could be either. Mapping it to
subscription would be the same species of confident-but-unfounded inference this ticket exists
to remove, so it must map to unknown.

Which leaves a genuine design decision, and it should be taken deliberately:

  • Treating unknown as billable is fail-safe against a surprise bill, but machine-login is the
    most common resolution (it is what auto produces), so this preserves today's bug for most
    users.
  • Treating it as non-billable removes the false block but could let real API spend run unbounded.

Recommended: stop inferring and ask. A one-time per-machine declaration — "Claude Code on this
machine signs in with: ( ) my Claude subscription ( ) an API key"
— is a fact the user knows for
certain and we cannot derive. Keep the env-derived signal as a cross-check and warn on
conflict, exactly as engineAuthWarning already does for mode-vs-resolved. Until it is answered,
unknown bounds by tokens (never blocked over money) and the Usage page says "payer not
established" rather than picking one.

One caveat to state in the UI rather than model: a subscriber drawing on usage credits is
being charged. We cannot detect that, so we should say we cannot, not imply we can.

Scope

  1. Migration: ai_usage.payer TEXT (NULL = unknown, no backfill — pre-existing rows genuinely are
    unknown).
  2. Thread authResolvedrecordEngineUsage → the row. Platform-paid rows get platform; rows
    made on a vault provider key get byok-api (those we know for certain — we injected the key).
  3. Per-machine payer declaration + conflict warning.
  4. Consumers read payer, not cost_source.

Verification

  • A row's payer is never inferred from machine-login; that resolves to unknown in the ledger.
  • Summing cost_micros without filtering on payer is not done anywhere after this lands — a
    test asserting the dollar aggregate filters is worth more than the aggregate itself.
  • A user with no declaration and a machine-login engine session is never blocked by a money
    limit, and can see on the Usage page that the payer is unestablished and why.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions