Agent docs

Usage and credits

> Evidence: 2 file:line witnesses as of 2026-08-18.

Evidence: 2 file:line witnesses as of 2026-08-18. Confidence: derived, written from the seams and the estate's failures rather than from citations; treat each rule as a design call and argue with it. What changes it: the first module built against this doc (../PLAN.md forge-1) and its instance breakdown.

How a module says what it spent, and how that becomes something a customer is charged for. The split is clean and stated once: the module meters, the app enforces. A module records every unit of cost as an idempotent event with the correlation ids on it; an app turns those into credits, plans, included bands, and overage, and decides what to do when a customer is over. Business input on pricing (a SKU through the workflow, meter only the expensive optional things) is satisfied by the events, not by logic in a module.

Audience: anyone writing a payload that spends money, a runner that records it, or an app that bills for it.

What the instances do#

versable-runnerspeedwaywalmart-mvp
what is recordedper item: duration, attempts, tokens, LLM cost by model (usage/{idx}.json, app/usage.py)per org per period counters, per-metric, plan limits, budgets, staff credits, soft/hard/overage modes (app/lib/usage/usage.server.ts, 833 lines)per org per day per kind counters (backend/app/usage.py)
whenon outcome writeas work proceeds, decoupled from run outcomein-process buffer flushed every 50 events or at job end
idempotencyone file per item, create-if-absentper-event ledger doc; a doc existing means counted; covers redelivery, resume, double submitnone; docstring says best-effort, "a crash can lose at most one autoflush buffer"
failure handlingn/adead-letter + replay, pinned to the original billing cycle; recordUsage never throwslossy by design
enforcementnonecheckLimits before work starts, throwsnone
tenant on the recordnoyes (org)yes (org)

speedway's engine is the shape. versable-runner's per-item cost detail is the number. walmart's is honest about being lossy and is therefore fine for a dashboard and wrong for a bill. App V5 is a fourth witness for the split: the worker is credit-unaware except for one final step that pushes a credit event to Redis for a separate credit worker, and idempotency is a Postgres UNIQUE(team_id, cycle_id, credit_key, part_number) with ON CONFLICT DO NOTHING, which its own notes call "explicitly the design, not a workaround" (.claude/notes/credit-flow.md:52-54). A database constraint is a fine ledger; the point is that dedup is structural, not app logic.

The rule#

Every unit of spend is one usage event: idempotent, tenant-scoped, correlated, never lost, never blocking.

  • Idempotent. Each event has an idempotency key derived from what it measures ({job_id}/{item_id}/{attempt}/{meter}), and the ledger dedups on it. Redelivery, resume, and a double submit cannot double-charge. speedway recordUsageTx and its per-event ledger doc are the witness.
  • Tenant-scoped and correlated. tenant, caller, env, job, item, attempt, capability, variant, judge, and the attribution keys, on every event. This is what makes "a SKU through the workflow" a sum instead of a project.
  • Never lost. Recording is retried; on failure it dead-letters with everything needed to replay, and replay lands in the period the spend happened, not the period the replay ran. Usage events are never deleted (05-storage-and-persistence.md, retention).
  • Never blocking. Metering never throws into the payload and never fails or reverts the work it measures. A metering outage is a dead-letter count, not a failed job. speedway states this as a design rule and it is adopted.

The event#

{
"event_id": "u_01H…",
"idem_key": "job_…/sku-1001/1/llm.tokens",
"occurred_at": "…", when the spend happened
"recorded_at": "…", when the ledger accepted it; differs on replay
"tenant": "t_speedway", "caller_id": "app_console_prod", "env": "prod",
"module": "content", "module_version": "2.3.0",
"job_id": "…", "client_job_id": "…", "item_id": "sku-1001", "attempt": 1,
"capability": "content.generate", "variant": { "research": "self" },
"attribution": { "user": { "id": "u_123", "label": "A. Chopra" }, "workflow_run": "wr_456" },
"meter": "llm.tokens", what was consumed, from the module's cost_model.meters
"quantity": 18432,
"unit": "tokens",
"detail": { "model": "gemini-3.5-flash", "prompt": 12000, "completion": 6432 },
"cost_usd": 0.0091, the module's best estimate at record time, may be null
"price_table_version": "2026-08-01" so the event can be re-priced later
}

contracts/usage-event.md § Fields carries the normative field list. Meters are declared in the manifest's cost_model.meters (11-capability-manifest.md) so an app knows what to expect before the first event: item (one per item attempt, always), llm.tokens, research.calls, vendor.<name>.calls, image.renders, storage.bytes, whatever the capability spends. An event per meter per attempt, not one blob per item, so a bill can weight them differently. Wall clock is a meter like the rest (duration, in ms) and so it gets its own event; it is not a field hung on every other one.

Where cost comes from#

The module fills cost_usd from a price table it ships (per model, per vendor call), versioned and reported in /health/deep. It is an estimate at record time; the app or a later reconciliation can re-price events from quantity and detail when the price table changes. versable-runner's by_model rollup already does the per-model half (../evidence/20260817-runner-four-file-diff.md).

Ceilings at the module#

The module enforces two hard ceilings, and only these:

  • per caller, from the caller context's budget (02-identity-and-tenancy.md)
  • per tenant, from module config: an operator-set safety floor keyed by tenant id, which is not the per-tenant behaviour configuration canon/14 bans from modules

Both are floors against runaway spend, expressed as concurrent jobs and items per window, returned as 429 with Retry-After. The module does not know about plans. A per-tenant monthly credit limit is the app's to enforce before it submits, the way speedway's checkLimits runs in the route action before createRunIfIdle (app/routes/workspaces/modules.tsx:259-321).

What the app does with the events#

Reads them (or receives them, if it asked for usage in callback_events), sums them by tenant, workflow run, and meter, applies its plan: included band per module, pay-as-you-go past it, "meter only the expensive optional stuff" (Christina's note; the app decides which meters count and at what price). Enforcement modes (soft warn, hard stop, overage allowed) are app-side and speedway has all three (../evidence/20260817-speedway-recon.md, usage metering row).

Reading usage back from a module#

GET /jobs/{job_id}/stats carries the rollup per job; GET /stats?usage=true carries it per tenant and window; and a paginated GET /usage/events?… filtered by tenant, job, window, and meter is the standard route for an app that reconciles rather than trusts callbacks. Every list paginated, every row carrying the ids.

Do-nots#

  • Do not record usage without an idempotency key. (walmart usage.py, lossy by design)
  • Do not let a metering failure fail or revert the work. (speedway's rule, adopted)
  • Do not drop a usage event on failure. Dead-letter it, replay it into the original period.
  • Do not record usage without tenant, job, item, and attempt. (versable-runner usage/{idx}.json has no tenant)
  • Do not put plan logic (included bands, overage) in a module. Emit events; the app decides.
  • Do not delete usage events. They are the billing record.
  • Do not emit one opaque blob per item. One event per meter per attempt.
@versable-git/ui · reference, canon, and method, read in place