Skip to content

M2-22 Costs: booking, price table and display

No Date Ruling Reason
D-M2-22-0 2026-10-23 Built on the old integration line (base int/m1-wave53) with M2-09 and M2-12 stacked, and ported to main after both landed: only the ticket's own commits were brought over as one change. The migrations are renumbered to 00072 and 00073, because main already uses 00061 to 00071; the additions to the German chapter files of the old base go to their English files on main; the module gets its own wiring file module_billing.go (start part in the step chat before runs, handlers; PL/28) instead of being built inside the runs step, and startRuns passes its billing.Costs to runs; changelog and rulings are fragments (PL/24); generated code is regenerated, never merged. The rules of the landing replace the integration branches; main is the reference.
D-M2-22-1 2026-10-21 Grants (design note 2 c): price_table gets SELECT, INSERT and the column grant UPDATE (valid_to) only; cost_entries gets SELECT, INSERT only. M2-28 and M7-05 add the grants they need. The column grant makes R-DM-23 hold in the database (only the end of a row can change), and entries are append only because a correction is a new row (03).
D-M2-22-2 2026-10-21 A second report with the same provider and model in one finishRun is 400 validation_failed (invalid, pointer on the second report's model); currency must match ^[A-Z]{3}$ (invalid_format) (design note 3). One entry per model is what 10 12.1 books; 03 principle 4 names ISO 4217, and bothy.currency must stay a bounded metric attribute (signals.md 2).
D-M2-22-3 2026-10-21 A report is computed only when its price set S(provider, model, occurred_at) is not empty, has one currency and has a row for every unit with a count above 0; the unit request is never used (spec gap: neither CostReport nor cost_entries carry a request count). The amount is computed exactly with math/big and rounded half up once per entry; at or above 2^53 it is 400 validation_failed (too_large) (design note 4 c). A set with a missing unit or two currencies cannot give a correct amount, and the adapter's amount or unpriced is then the honest result; exact integers with one rounding make every computed entry recomputable to the micro (R-DM-23).
D-M2-22-4 2026-10-21 A booking is never recomputed after it is written: an entry booked adapter_reported or unpriced stays so when a price for its time is added later (design note 4 e). A booking reflects the price table at its time; a later price row must not change booked history, and the reconciliation of M2-28 is the only path that replaces an estimate.
D-M2-22-5 2026-10-21 A repeat of the same end state in finishRun books nothing; only a real transition with a non-empty costs books. A run without billing_mode_used (it never started) and a non-empty costs gives 400 validation_failed with code invalid on /costs, and the unit rolls back (design note 5 b). 06 2 "No effect" and M2-09 make a retried finish harmless, so it must never book twice; a runtime starts only after run.start (09 4.9), so a run that never started consumed nothing, and cost_entries.billing_mode needs the actual mode (10 8).
D-M2-22-6 2026-10-21 One clock reading is both runs.finished_at and occurred_at of every entry of the finish (design note 5 c). 10 12.1 books "at the end of a run", and occurred_at is the time whose prices apply.
D-M2-22-7 2026-10-21 The run columns take the booking in the update of the transition: cost_micros is the sum in the currency of the first entry, currency that currency, cost_is_estimate = true; other currencies stay only in cost_entries. A finish without costs or with an empty list keeps the columns as they were (design note 5 f). Spec gap: 03 runs.currency holds one currency, and amounts are never converted (10 12.1); one update raises version once and the one run.state_changed carries the booked cost (09 5.4).
D-M2-22-8 2026-10-21 The session sum (agents.Sessions.AddCost, once per currency of the booking): a row whose cumulative_cost_micros and cost_baseline_micros are both 0 first takes the booking's currency; an amount in another currency is skipped; version rises only when a value changes; no agent_session.changed is written; an empty currency or a negative amount is an internal error (design note 5 g). No conversion (10 12.1); 03 principle 10 raises the version on a change only; 05 6 names the causes of agent_session.changed and a booking is none of them; billing validated the reports before the unit, so a bad value is a programming error.
D-M2-22-9 2026-10-21 Runs the server ends (stale, interrupted, cancel_timeout, agent_disabled, access_revoked without a later finishRun transition) get no cost entry (design note 5 i). 09 4.10 names run.finish as the only source of the usage per model; the lost consumption is part of RK-16 and named in the billing README.
D-M2-22-10 2026-10-21 The server books the reported values as the consumption of the run and never subtracts agent_sessions.cost_baseline_micros (design note 6). Spec gap: the API cannot mark a report as a session sum and the adapter never sees the baseline (AgentSession omits it); the baseline of imported sessions belongs to M8-06 (RK-75).
D-M2-22-11 2026-10-21 A correction entry repeats the token counts of the entry it supersedes (design note 7); the billing README states it for M2-28. The valid sum counts only entries without a successor, so token sums would otherwise drop after a reconciliation.
D-M2-22-12 2026-10-21 getThreadCosts answers CostBreakdown with totals (CostTotal per billing mode and currency), runs (RunCost with tokens of TokenCounts, costs per currency, unpriced, optional agent_session_id); this replaces total and RunCost.cost (API 0.x, 06 13 point 1). The answer never names a person (design note 8 c). 10 12.1 requires sums per currency, 10 12.2 hides amounts for personal and shows tokens, and "Price missing" needs a marker; in threads only costs per run (20 9); only getThreadCosts used the old schemas.
D-M2-22-13 2026-10-21 The runs of getThreadCosts stand by run_id ascending and are not paginated (design note 8 e). UUIDv7 gives the order of the runs; the schema has no pagination (spec gap for very long threads).
D-M2-22-14 2026-10-21 Run.cost, RunSummary.cost, the run document member /cost and AgentSession.cost carry the stored values in both modes; clients show amounts from them only for organization; amounts the switch billing.show_personal_estimates may reveal for personal come only from getThreadCosts (later getCostStats), where the server applies the switch; 10 12.2 states it (design note 9). The members are required Money of M2-05, M2-10 and M2-12, and the switch is a server key that clients cannot read (spec gap).
D-M2-22-15 2026-10-21 listPrices returns every row, closed ones included, ordered by provider, model, unit, valid_from, id, unpaginated; 06 section 6 point 6 lists it (design note 10). The price table is a small list in the sense of 06 6 point 6; PriceList has only items.
D-M2-22-16 2026-10-21 createPrice accepts only an absolute https source_url with a host and without user info or fragment, and no retrieved_at after now (design note 11 b). The form of source_url is the one the audit value kind accepts, so every valid row can be audited. 03 calls the source the "public pricing page", and a retrieval in the future is not a fact; one form in both places keeps a valid price from failing its own audit entry.
D-M2-22-17 2026-10-21 createPrice answers 409 conflict with reason price_currency_mismatch when a row of the same provider and model (any unit) overlaps in another currency, and price_overlap on an exclusion or unique violation (SQLSTATE 23P01 or 23505). Two concurrent inserts of different units and currencies can both pass the currency check; the set is then incomplete and gives no computed amount (design note 11 c). Note 4 b needs one currency per price set; the race cannot produce a wrong amount, so no extra lock is needed.
D-M2-22-18 2026-10-21 closePrice answers 409 conflict with reason price_closed for another end on a closed row and price_in_use for an end that cuts off a computed booking; a closed row is never reopened or moved, a gap is filled by a new row (design note 12 e). R-DM-23: a row used by a booking changes only by its end, and every computed booking stays recomputable.
D-M2-22-19 2026-10-21 New audit value kind auditing.SourceURL, accepted only under the key source_url and only for an absolute https URL without user info and fragment, validated again in audit/domain; 19 SEC-AUD-5 lists the member (design note 14). 10 14 row "Prices" requires the source in the audit, and SEC-AUD-5 forbids free text unless a ticket extends the schema.
D-M2-22-20 2026-10-21 The start file has rows only for the models whose model identifier the model overview page names (claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5); the legacy models of the main price table are left out until a source names their identifiers (design note 13). Haiku 5.5 takes the row "for prompts up to 100,000 tokens" as its base price (no tiers, 10 12.3). Provider anthropic. Source https://platform.claude.com/docs/en/about-claude/pricing, read 2026-10-21T07:51:05Z (valid_from equals retrieved_at); identifiers from https://platform.claude.com/docs/en/about-claude/models/overview, read 2026-10-21. No loader. The pricing page shows only display names, and the overview names identifiers only for the current models; CostReport carries the identifier the runtime reports (09 18.7); the CostReport examples and the runtimes name the provider anthropic; loading the file on the host needs a system action that 05 does not have (spec gap).
D-M2-22-21 2026-10-21 The metric attribute bothy.currency is bounded by the pattern source SourceCurrencies (^[A-Z]{3}$) of the metric registry; bothy.run.cost is a float counter of amount_micros / 10^6 per booked entry, recorded after the commit (design note 5 h). Currencies come at runtime from the price table and the reports, so no value list exists; the pattern keeps the attribute bounded (signals.md 2); the unit {currency_unit} needs fractions.
D-M2-22-22 2026-10-21 Names differ from the brief and follow the code: the domain types Report, Tokens, PriceInput, PriceRow, Priced, EntryRow, Total, ThreadBreakdown and the domain functions Breakdown, Valid, Sum, ShowAmounts; the runs port DTO runs.CostReport with flat token fields and FinishRun.Costs; app.CostColumns in TransitionStore.Cost and the time TransitionStore.At, the unit of work of the finish in finishInUnit; the agents store method SessionStore.SetSessionCost; the runs metrics port BookedCost and Metrics.CostBooked; the billing module is built by its own start part in the step chat before runs (startBilling, NewBilling) and mounts its own handlers; the integration world names the owner owner, who stands for Robin of the brief. Brief names predate the code (Boundaries); runs is the only caller of the booking, so billing starts right before it in the same step.
D-M2-22-23 2026-10-21 The M2-09 integration assertion of the not_enabled answer for costs is replaced by a comment; the costs tests in test/integration/costs cover the booking. D-M2-09-14 is replaced by D-M2-22-5; an assertion of the removed answer would fail.
D-M2-22-24 2026-10-21 createPrice and closePrice authorize before they validate or read; an unknown price_id is 404 only for an owner with step-up. Every caller without the right gets the same 403 or 401 whatever the body or the id, so the price table leaks no existence or validation detail to a non-owner; design note 1 lets each use case call authorize before anything else, and note 11 a puts it first.