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.mdforge-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-runner | speedway | walmart-mvp | |
|---|---|---|---|
| what is recorded | per 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) |
| when | on outcome write | as work proceeds, decoupled from run outcome | in-process buffer flushed every 50 events or at job end |
| idempotency | one file per item, create-if-absent | per-event ledger doc; a doc existing means counted; covers redelivery, resume, double submit | none; docstring says best-effort, "a crash can lose at most one autoflush buffer" |
| failure handling | n/a | dead-letter + replay, pinned to the original billing cycle; recordUsage never throws | lossy by design |
| enforcement | none | checkLimits before work starts, throws | none |
| tenant on the record | no | yes (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. speedwayrecordUsageTxand 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/14bans 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}.jsonhas 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.