M2-22: Costs: booking, price table and display¶
Status: ready | Depends on: M2-12 | Milestone: M2
Goal¶
After this ticket the server books the consumption an agent adapter reports when it ends a run: finishRun with costs writes one cost_entries row per model, priced from the price table as computed when the price set for provider, model and booking time covers every consumed unit, otherwise adapter_reported with the adapter's amount, otherwise unpriced with amount 0 ("Price missing"); every row is an estimate, carries the actual billing mode of the run and copies of agent, thread, channel, space and session, and amounts of different currencies are never converted. The owner maintains the price table with listPrices, createPrice and closePrice (owner, step-up and audit for changes, source and retrieval time on every row, no overlapping validity, a used row never changes except that it can be closed, and closing never breaks a booking), and the product ships a start file with prices, source and retrieval date that a test checks; the owner enters prices with createPrice (loading the file on the host needs a system action that 05 does not have, spec gap). Every reader of a thread gets its costs with getThreadCosts: per run and per currency, summed per billing mode and currency without conversion, with token counts always and with amounts of runs on a personal subscription only when billing.show_personal_estimates is on. The billing port offers the same valid sums per run, thread, session, agent, space and instance for the statistics of M6-05. This is BR-09 and the server part of BR-11 (10 "Tests"), the ruling D-BR-01 and the estimate marking that RK-16 relies on; replacing estimates by reconciled amounts stays with M2-28.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope: "10 with ... costs and reconciliation"; Out of scope; Exit criteria items 2 and 10; Test strategy: fake model (no costs); Red team first paragraph; Risks: "the cost estimate differs from the invoice (label 'Estimate' in M2-22, reconciliation in M2-28)"), block "agents and extensions" rows M2-09 (what exists), M2-10, M2-12 (sessions, see Boundaries), M2-22 (this ticket), M2-28 (reconciliation), M2-33, M2-34 (adapters that report costs); section 12 row M6-05 (getCostStatson top of this port); section 10 rows M4-21 and M4-28 (client display); section 14 row M8-06 (cost baseline of imported sessions).- Dependencies: the header names M2-12 (D-W-103: the booking adds to the session sum and needs the FK of
cost_entries.agent_session_idonagent_sessions). Through it this ticket also builds on M2-09, M2-08, M2-07, M2-06, M2-05, M2-04 and M2-02 and on the M1 tickets M1-07 and M1-08 (configuration schema and reference), M1-10 (meter provider), M1-12 (migrations,pgtest,make migrations-lock), M1-13 (unit of work), M1-16 (event log), M1-17 (api/operations.yaml, strict server), M1-18 (HTTP chain, problem details,errors[].codevalues), M1-19 (rate limits), M1-20 (Idempotency-Key), M1-24 (auditing.Recorder), M1-25 (registration duty), M1-26 (principals,kernel/textcheck), M1-28 (authorize, requirement table), M1-31 (owners), M1-35 (step-up), M1-44 (agent API tokens), M1-51 (agents), M1-52 to M1-56 (spaces, channels, operators, threads, messages); all implied, so not in the header. docs/spec/10-backend-policy.md: 8 "Expected and reported" (rowcost_entries.billing_mode), 12 "Costs" with 12.1 "Recording", 12.2 "Display", 12.3 "Price table" and 12.4 "Reconciliation with billing" (only to know what M2-28 adds), 14 "Audit trail" (row "Prices"), 15 "Tests" (BR-09, BR-10, BR-11), 17 "Configuration values of this chapter" (billing.show_personal_estimates).docs/spec/03-data-model.md(core contract, read only): 1 "Principles" points 1, 4, 10, 11, 12 and 13; 4.9 "Costs (billing)" withprice_tableandcost_entries(every column, check, FK, unique, exclusion constraint, index, the paragraphs after the tables);runs(billing_mode_used,cost_micros,currency,cost_is_estimate,agent_session_id,finished_at);agent_sessions(cumulative_cost_micros,cost_baseline_micros,currency,cost_is_estimate,version); the sentence "cost_entriesandevent_logkeep the channel at the time of the booking" in the paragraph on moving threads afterthreads; 6.3 "Lock order"; 8 "Decisions of this chapter" R-DM-04 and R-DM-23.docs/spec/09-agents.md: 4.7 "Adapter operations" (rowrun.finish), 4.10 "Usage at the end of a run", 5.4 "Live box content" (row "Costs": only fororganization), 9.3 "Previous sessions", 18.7 "Costs" (the runner reports the difference per run as an estimate).docs/spec/05-actions.md(core contract, read only): rules 1.2 and 1.10 (system actions); 2.3 "Effective level" point 1 (an owner's personal token has no owner rights); 2.4 (Step-up, Audit); 2.5 "Decision"; 2.6 (no scope forcost.read.thread); 2.7 (cost.read.threadhas effectread); table 4.12 rowscost.read.thread,cost.read.aggregate,price.read,price.manage,cost.reconcile,cost.reconcile.submit; R-AZ-01, R-AZ-03, R-AZ-15.docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "Integers", "Money", "Nonull", "Unknown values"), 5 "Errors" (points 3, 4 and 7,errors[].code), 6 "Pagination" point 6 (unpaginated small lists), 7 "Idempotency", 13 "Versioning, deprecation, compatibility" point 1 (0.xuntil the first release), 16 "Endpoints" group "Stats".docs/spec/07-permissions.md: 2.10 "Apps"; 4.1 "Decisions and reasons" (owner_only,step_up_not_possible,subject_kind_not_allowed); the world of 11.1 and its row AZ-M34; test rows AZ-P1 and AZ-P4 in 11 "Tests".docs/spec/02-architecture.md: 3.3 "Commands of the binary" (the command register); 4.2 "Module list" and 4.3 "Ports, events and sources per module" (rowbilling:Costswith booking, correction and sums,Prices); 4.4 "Allowed dependencies" (billinguses nothing;runsmay usebillingandagents); 4.6 "Actions per module" (rowbilling); 5 "Layers within a module" (rule 5 on metrics); 6 "Code layout"; 8.1 "Flow", 8.2 "Lock order"; 9.2 "404 instead of 403".docs/spec/19-security.md: 6.1 SEC-IN-1 and SEC-IN-4; 12 SEC-AUD-5 (schemaaudit_details.v1, and its sentence that a ticket needing another free-text form extends the schema).docs/spec/20-privacy.md: 2 rowsprice_tableandcost_entries(classcost); 9 "Codetermination" row "Costs per person" (in threads only costs per run, never per person).docs/api/openapi.yaml: operationsgetThreadCosts,listPrices,createPrice,closePrice,finishRun; schemasCostBreakdown,RunCost,Money,BillingMode,RunFinish,CostReport,Price,PriceCreate,PriceClose,PriceList,PriceUnit,Run,RunSummary,AgentSession,RunStateChangedPayload,Problem(x-problem-codes); parametersThreadIdPath,PriceIdPath,IdempotencyKey; responsesConflict,UnprocessableEntity.- ADRs 0008, 0013, 0015, 0017, 0021, 0024, 0049, 0050, 0053, 0055.
docs/DECISIONS.md: D-BR-01 (booking order, no conversion, no amounts forpersonalin the interface), D-BR-02, D-K-D25 to D-K-D28, D-W-100, D-W-103; section 10 for the format of new rows and the rowsD-M2-09-<n>(thenot_enabledanswer forcoststhat this ticket replaces).docs/RISKS.md: RK-16 (estimate deviates from the invoice), RK-75 (double booking of imported sessions, owned by M8-06).docs/security/threat-model.md: T-AI-07 and T-AI-08 (consumption and abuse of a personal subscription). No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/observability/signals.md: 2 "Cardinality"; 4.3 "Agents and runs" rowbothy.run.cost(attributesbothy.agent.handle,bothy.billing_mode,bothy.currency,bothy.cost.estimate).docs/operations/configuration.md:billing.show_personal_estimates,retention.cost,database.tx_retry_max,ratelimit.rl.read.per_minute,ratelimit.rl.admin.per_minute,auth.step_up_max_age,audit.deny_aggregate_window.- Briefs: M1-12, M1-13, M1-17, M1-18 (
StageValidateand itserrors[].codelist), M1-20, M1-24 (Entry,Attrs, value kinds), M1-26 (textcheck.DisplayText), M1-28 (requirement table:readgivesLevel(read),ownergivesOwner; app scope table), M1-55 (chat.Threads.Info,ThreadInfo), and the M2 briefsM2-05-runs-state-machine.md(runs migration,Runrepresentation, metrics port ofruns),M2-09-run-lifecycle.md(Design note 6:finishRun, the end-of-run function, thenot_enabledanswer forcosts),M2-10-run-document-live-box.md(Design note 12: the/costpatch and "the rule of the clients and of M2-22"),M2-12-agent-sessions.md(Out of scope and Design notes 3 and 14: booking intocumulative_cost_microsand the FK ofcost_entries.agent_session_idare left to this ticket). - Code on the base branch:
internal/billing(README anddoc.goonly),internal/runs(M2-05 to M2-09:Finish,FinishRun, the end-of-run function, the metrics portMetrics,adapters/otelmetrics),internal/agents(M1-51, M2-07, M2-12:Sessions),internal/chat(Threads.Info),internal/kernel/authorize,internal/kernel/auditing,internal/audit(details validation),internal/kernel/txn,internal/kernel/clock,internal/kernel/errs,internal/kernel/textcheck,internal/wire(configkeys_*.go,configchapters.go),cmd/bothy,architecture/modules.yaml,sqlc.yaml,api/operations.yaml,migrations/. Merged M1 code can be read withgit show main:<path>.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Note on rule 8: the start file (Design note 13) is product data, not an example. It names provider and models exactly as the pricing page cited in 10 12.3 and the runtimes do; every test fixture still uses invented names (example, model-large) and example.com.
Product requirements implemented here:
- At the end of a run the server writes one
cost_entriesrow per model ofRunFinish.costs:computedwhenprice_tablehas the price forproviderandmodelatoccurred_at, elseadapter_reportedwith the adapter's amount, elseunpricedwith amount0(shown as "Price missing");computedhas precedence overadapter_reported(10 12.1, 09 4.10, 03cost_entries, BR-09, D-BR-01). - A
computedbooking refers to the price set forprovider,modelandoccurred_at, never to a single row; a row used by a booking never changes again except that it can be closed withvalid_to, so everycomputedbooking stays recomputable (03price_table, R-DM-23, 10 12.1). - Every entry is an estimate (
is_estimate = true) until a reconciliation replaces it; amounts of different currencies are never converted and sums stand per currency; the valid sum counts only entries that no other entry supersedes (10 12.1, 03cost_entries). cost_entries.billing_modeis the actual mode of the run (runs.billing_mode_used);agent_id,thread_id,channel_id,space_idandagent_session_idare copies taken at booking time (10 8, 03cost_entriesand the paragraph on moving threads).- Amounts are shown only in mode
organization; in modepersonalthe reader gets the mode (the badge "Personal") and the token counts, no amounts, unlessbilling.show_personal_estimatesis on (10 12.2, BR-11, D-BR-01). cost.read.threadneedsreadon the thread, writes no audit entry and usesrl.read; without read right the answer is the same404as for an unknown thread (05 4.12 and rule 1.2, R-AZ-01, AZ-P4).price.readis owner only, without step-up and audit,rl.read;price.manageis owner only with step-up, audited,rl.admin, withIdempotency-Key; step-up actions are refused for tokens (05 4.12 and 2.4, R-AZ-03).- Every price row has
source_urlandretrieved_at; validities of one provider, model and unit never overlap (exclusion constraint with the extensionbtree_gist) (03price_table, 10 12.3). - The product ships a start file with prices, source and retrieval date, and a test checks that every row has source and retrieval date; the specification itself names no prices (10 12.3, PLAN row M2-22).
- A price change is audited with row, source and retrieval time, and audit details keep the forms of
audit_details.v1(10 14, 19 SEC-AUD-5). - Money is
amount_micros(int64) plus an ISO 4217currency; integers stay below the bound of 06 "Basic rules" (row "Integers"); no JSONnull(03 principle 4, R-DM-04, 06 2, ADR 0053). bothy.run.costis counted from the booked entries with exactly the attributes of signals.md 4.3 (signals.md, 02 5 rule 5).
Scope¶
All paths under services/server/ unless they start with docs/. Names of M2-05 to M2-09 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
migrations/<next number>_billing_price_table.sqlandmigrations/<next number + 1>_billing_cost_entries.sql(plan: migrationsprice_table,cost_entries; Design note 2).- Port
internal/billing(next to the existingdoc.go): interfacesCostsandPricesand their DTOs (Design notes 1, 5, 7, 8, 10 to 13). internal/billing/domain/report.go(ValidateReports),price.go(ValidatePrice,PriceSet,Compute),booking.go(Book),sums.go(valid sums, display rule),closing.go(CloseDecision), all pure, with tests.internal/billing/domain/startprices/:start_prices.json(embedded with//go:embed) andstartprices.go(Rows() ([]PriceInput, error)), with tests (Design note 13).internal/billing/app/costs.go(ValidateReports,BookRun,ThreadCosts,Totals),prices.go(List,Create,Close),ports.go(repository, clock and configuration interfaces).internal/billing/adapters/postgres/queries/prices.sql,queries/cost_entries.sql, the sqlc output and the repository.internal/billing/adapters/httpapi/: strict-server methodsGetThreadCosts,ListPrices,CreatePrice,ClosePrice.internal/billing/adapters/billingtest/(kept out of the binary like M1-33'sidentitytest):InsertEntry(any source, alsoreconciledwithsupersedes_id, for tests of the valid sum) andRecomputeComputed(ctx) ([]Mismatch, error)(Design note 4).internal/wire/configkeys_billing.go: the keybilling.show_personal_estimates(chapter constantchapter10inconfigchapters.goif absent; a parallel ticket adds the same constant with the same name and text).test/integration/costs/: the cross-module tests of this ticket.
Change:
internal/runs(port, app, adapters):FinishRuncarries the reports (replace the flagCostsPresentof M2-09 byCosts []CostReport, a DTO of therunsport with the fields ofbilling.CostReport, because a port imports onlykernel(02 5); the app maps it tobilling.CostReport); the finish use case books inside its unit of work (Design note 5); therunsmetrics port getsCostBookedand its OpenTelemetry adapter recordsbothy.run.cost(Design note 5 h);adapters/httpapimapsRunFinish.costsand drops thenot_enabledanswer of M2-09.internal/agents(port, app, postgres):Sessions.AddCost(ctx context.Context, cmd AddSessionCost) errorwithAddSessionCost{SessionID ids.ID; AmountMicros int64; Currency string}(Design note 5 g).internal/kernel/auditingandinternal/audit/domain: the value kindSourceURL(Design note 14).internal/authz: only the testTestCostReadThreadRequirement(Design note 16, Tests), no code change.internal/wire: the billing handlers in the embedded strict server, the billing port intoruns, the config key.api/operations.yaml: movegetThreadCosts,listPrices,createPrice,closePricefromlatertoenabled; rerun the route generator (M1-17).sqlc.yaml: entry forbilling.architecture/modules.yaml: the vendor groups the newbillingadapters need inadapter_vendors(as merged modules do for postgres adapters),depends_onofbillingstays empty; regenerate.go-arch-lint.ymlwithgo generate ./tools/archgen.docs/api/openapi.yaml(Design note 8 and 9): schemasCostBreakdown,RunCost, newCostTotalandTokenCounts, the example ofgetThreadCosts, the descriptions ofRunFinish.costs,CostReport,PriceCreate,PriceClose,listPricesandgetThreadCosts. Thenmake api-lint,make api-checks,make api-tablesand the server code generation.docs/spec/10-backend-policy.md(English, like the chapter): 12.1 the booking details of Design notes 4 to 6; 12.2 the server side of the display rule (Design notes 8 and 9); 12.3 the start file (embedded, checked by a test, not loaded by the server), the rules ofcreatePriceandclosePrice(Design notes 11 to 13).docs/spec/09-agents.md4.10 (English): the field iscosts(CostReport),currencyis required, and the values are the consumption of this run (Design note 6).docs/spec/19-security.mdSEC-AUD-5 (English): the membersource_url(Design note 14).docs/spec/06-api.md6 point 6: the price table is one of the small unpaginated lists (Design note 10).docs/operations/configuration.md: regenerate withmake config-referenceif the schema text changes.- READMEs of
billing,runs,auditandagents;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-22-<n>for every Ruling below, and in the M2-09 row aboutnot_enabledforcoststhe note thatD-M2-22-<n>replaces it.
This ticket creates no new top-level make target, no event type, no job, no action and no problem code.
Out of scope¶
- Reconciliation:
reconcileCosts,submitCostReconciliation, the estimate listlistCostEstimates(cost.reconcile.read, R-AZ-28), the jobbilling.reconcile, entries withsource = 'reconciled'from a real path, settingruns.cost_is_estimateandagent_sessions.cost_is_estimatetofalse, BR-10 (M2-28). This ticket only reads such entries correctly (valid sum). - The aggregate cost API
getCostStatswithcost.read.aggregate, cost members in statistics, live frames and the backend view (M6-05, M6-14); they use the port sums of Design note 7. - The client display: badges "Personal" and "Organization", the labels "Estimate", "Price missing" and "Information, not billed" (M4-21, M4-28; console in M6).
- Subtracting
agent_sessions.cost_baseline_microsfor runtimes that report session sums (M8-06, RK-75; Design note 6, spec gap). - The table
agent_sessions,runs.agent_session_idand binding sessions (M2-12); this ticket adds to the session sum (Design note 5 g). - Live cost values during a run through the
/costpatch (M2-10); cost limits per agent (not specified, spec gap). - The retention job
retention.cost(M7-05);bothy.run.tokens(no owning ticket, plan gap). - How adapters compute what they report: SDK (M2-29, M2-30), Hermes Agent adapter (M2-33), Claude-Code-Runner (M2-34).
- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-22-costs; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(billing): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules, and plain reads without account of the public pricing page cited in 10 12.3 and, only if that page shows display names instead of model identifiers, the model overview of the same documentation site, to fill the start file (Design note 13).
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for M2-05 to M2-09: their merged names win over the expected names here (for example
FinishRun, the end-of-run function, the metrics port ofruns); list each difference. - M2-12 is merged on the base branch (header, D-W-103):
agent_sessions,runs.agent_session_idand the portagents.Sessionsexist; otherwise stop withVerdict: BLOCKED dependency M2-12 missing. - No real agent runtime, no model call and no third-party service in tests: the adapter in the tests is plain Go HTTP code with an agent API token; booking tests take their prices from
createPriceor the test helper, never from the start file (only the start file tests read it). - Never write a price from memory or from another source into the start file; if the pricing page cannot be read in the build environment, stop with
Verdict: BLOCKED start file: pricing page not readable. The specification and the docs name no prices (10 12.3). - Time only through
kernel/clock; tests move a fake clock and never sleep.
Design notes¶
-
Module shape (02 4.2 to 4.6, 5).
billingownsprice_tableandcost_entries, uses no other module port (02 4.4) and gets everything it copies from its caller. Portinternal/billing:// Costs books run costs and reads valid sums (02 4.3: booking, correction, sums). type Costs interface { ValidateReports(reports []CostReport) error BookRun(ctx context.Context, cmd BookRun) (Booking, error) ThreadCosts(ctx context.Context, threadID ids.ID) (ThreadCosts, error) Totals(ctx context.Context, q TotalsQuery) ([]Total, error) } // Prices maintains the price table (10 12.3). type Prices interface { List(ctx context.Context) ([]Price, error) Create(ctx context.Context, cmd CreatePrice) (Price, error) Close(ctx context.Context, cmd ClosePrice) (Price, error) } type CostReport struct { Provider, Model string TokensIn, TokensOut, TokensCacheRead, TokensCacheWrite, TokensCacheWrite1h int64 AmountMicros *int64 // absent lets the server compute (CostReport.amount_micros) Currency string } type BookRun struct { RunID, AgentID, ThreadID, ChannelID ids.ID SpaceID, AgentSessionID ids.ID // zero value: none (DM channel, no session) BillingMode string // runs.billing_mode_used OccurredAt time.Time Reports []CostReport }Booking{Entries []Entry; Sums []CurrencySum}withEntriesin the order ofReportsandCurrencySum{Currency string; AmountMicros int64}in the order in which a currency first appears inEntries.ValidateReportswraps the puredomain.ValidateReportsof note 3, sorunscan check a request before its unit of work without importing a foreign domain (02 5).ids.IDstands for the merged id type ofkernel/ids.ThreadCosts,List,CreateandCloseserve the ownhttpapiadapter: each use case takes the request principal from the context (kernel/actor) and callsauthorizebefore anything else (notes 8, 10 to 12).BookRunandTotalsnever callauthorize:BookRunruns inside the unit of work ofrun.finish, whichrunsauthorized, and every caller ofTotals(M6-05) authorizes first and passes the visible channels. -
Migrations exactly as 03 4.9, nothing more: a.
price_table: every column with type, null and default of 03;CHECK (unit IN ('input_mtok','output_mtok','cache_read_mtok','cache_write_mtok','cache_write_1h_mtok','request')); FKcreated_by -> principals(id) ON DELETE RESTRICT; unique(provider, model, unit, valid_from);CREATE EXTENSION IF NOT EXISTS btree_gist(PostgreSQL marks it trusted, so the database owner role that runs migrations in M1-12 can create it; the migration test proves this, and if it fails stop withVerdict: BLOCKEDand the error instead of widening a role) andEXCLUDE USING gist (provider WITH =, model WITH =, unit WITH =, tstzrange(valid_from, valid_to) WITH &&); index(provider, model, unit, valid_from DESC).tstzrangehas the bounds[)and an absentvalid_tois open, which is the validity rule of Design note 4. b.cost_entries: every column of 03 withCHECK (billing_mode IN ('personal','organization'))andCHECK (source IN ('adapter_reported','computed','unpriced','reconciled')); FKsrun_id -> runs(id),agent_id -> agents(principal_id),thread_id -> threads(id),channel_id -> channels(id),space_id -> spaces(id),supersedes_id -> cost_entries(id), allON DELETE RESTRICT;agent_session_id -> agent_sessions(id) ON DELETE RESTRICT; unique partial index(supersedes_id) WHERE supersedes_id IS NOT NULL; indexes(space_id, occurred_at),(agent_id, occurred_at),(thread_id),(run_id),(is_estimate, occurred_at) WHERE is_estimate. c. Grants (M1-12 D4, least privilege):price_tableSELECT, INSERTandUPDATE (valid_to)only;cost_entriesSELECT, INSERTonly. Ruling for docs/DECISIONS.md: 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); M2-28 and M7-05 add the grants they need. d. Numbering by M1-12 D6; renumber before the merge if another ticket took the number. -
Validation of reports (
domain.ValidateReports, pure; pointers relative to the list,runsprefixes/costs; codes from the list of M1-18):providerandmodelnon-empty after trimming and passingtextcheck.DisplayText(required,invalid); every token field andamount_microsnot negative (too_small) and below the integer bound of 06 "Basic rules" (too_large);currencymatches the ISO 4217 form^[A-Z]{3}$(invalid_format); a second report with the sameproviderandmodel(invalid, pointer to the second). Absent token fields count0. Ruling for docs/DECISIONS.md: the duplicate rule, because one entry per model is what 10 12.1 books, and the form check of the currency, because 03 principle 4 names ISO 4217 andbothy.currencymust stay a bounded attribute (signals.md 2). -
Price set and computation (10 12.1 and 12.3, 03
price_table, R-DM-23): a. The price setS(provider, model, T)is every row with equalproviderandmodel(exact, case-sensitive match) andvalid_from <= T < valid_to(absentvalid_tois open). Units map to counts:input_mtoktotokens_in,output_mtoktotokens_out,cache_read_mtoktotokens_cache_read,cache_write_mtoktotokens_cache_write(5 minutes or no stated duration),cache_write_1h_mtoktotokens_cache_write_1h. A unit is needed when its count is above0.requestis never used, because neitherCostReportnorcost_entriescarry a request count (spec gap). b.computedwhenSis not empty, all rows ofShave one currency, and every needed unit has its row inS; the entry's currency is that currency. Otherwiseadapter_reportedwhen the report hasamount_micros(amount and currency of the report), otherwiseunpricedwithamount_micros = 0and the report's currency. The adapter's amount is ignored when the entry iscomputed(10 12.1). c. Amount: the sum over the needed units of count timesprice_micros, divided once by one million (prices are per million tokens, unit*_mtok), computed exactly with integers (math/bigor 128-bit arithmetic frommath/bits) and rounded half up once per entry; a result at or above the integer bound of 06 "Basic rules" is a400 validation_failed(too_large) for the report. Ruling for docs/DECISIONS.md: completeness, one currency, the unusedrequestunit, exact arithmetic and one rounding per entry. d.billingtest.RecomputeComputedrecomputes everycomputedentry fromS(provider, model, occurred_at)with the same function and reports differences; tests run it after every price change (R-DM-23). e. A booking is never recomputed after it is written: an entry booked asadapter_reportedorunpricedstays so when a price for its time is added later. Ruling for docs/DECISIONS.md. -
Booking in
finishRun(09 4.7 rowrun.finish, 4.10; 10 12.1; M2-09 Design note 6), one unit of work, in this order: a. Before the unit of work:billing.Costs.ValidateReportsonRunFinish.costs; an error is400 validation_failedwith pointers/costs/<i>/<field>and changes nothing (06 5 point 4). b. In the unit of work, after the run row is lockedFOR UPDATEand the finish decision of M2-09 is known: a repeat of the same final state books nothing (M2-09 6 b, 06 2 "No effect"), so a retriedfinishRunnever books twice. Only a real transition with a non-emptycostsbooks. A run withoutbilling_mode_used(it never started) and a non-emptycostsgives400 validation_failedwitherrors[].codeinvalidand pointer/costs, and the unit rolls back. Ruling for docs/DECISIONS.md: a runtime starts only afterrun.start(09 4.9), so a run that never started consumed nothing, andcost_entries.billing_modeneeds the actual mode (10 8). c. One clock readingnowis bothruns.finished_atandoccurred_atof every entry. Ruling for docs/DECISIONS.md: 10 12.1 books "at the end of a run", andoccurred_atis the time whose prices apply. d. Copies:agent_id = runs.agent_id,thread_id = runs.thread_id,channel_idandspace_idfromchat.Threads.Infoat this moment (space absent for a DM channel),agent_session_id = runs.agent_session_idwhen set,billing_mode = runs.billing_mode_used(10 8, 03). e.billing.Costs.BookRun: for each report in order, loadSwithSELECT ... FOR SHARE(rows in ascendingid, 03 6.3), apply note 4, insert the entry with a new UUIDv7,is_estimate = true,reconciled_atandsupersedes_idabsent. Lock order of the whole unit: run row, price rows, session row, then the counters of the unit of work (03 6.3, 02 8.2); deadlocks are retried by the unit of work (database.tx_retry_max). f. Run columns:cost_microsis the sum of the new entries in one currency,currencythat currency,cost_is_estimate = true, written by the end-of-run function of M2-09 in the same update as the transition, soversionrises once and the onerun.state_changedcarries the booked cost (09 5.4: the collapsed row is built from this event). When the entries have one currency that is the currency; with several, the currency of the first entry (entries follow the order ofcosts, and acomputedentry has the currency of its price set), and the other currencies stay only incost_entries. Ruling for docs/DECISIONS.md (spec gap: 03runs.currencyholds one currency). A finish withoutcosts, or with an empty list, books nothing and leaves the cost columns as the live/costpatches of M2-10 left them. g. Session sum, when the run has a session:agents.Sessions.AddCostper currency of the booking, inside the same unit of work: lock the session rowFOR UPDATE; whencumulative_cost_microsandcost_baseline_microsare both0, setcurrencyto the booking's currency first; add the amount only when the currencies are equal, otherwise skip it (no conversion, 10 12.1); raiseversionby one (03 principle 10); write noagent_session.changed, because 05 6 names its causes and a booking is none of them. Ruling for docs/DECISIONS.md. h. Metrics after the unit of work committed, never inside (a retried unit would count twice, M2-05 note 11):bothy.run.costper entry, valueamount_microsdivided by one million (unit{currency_unit}), attributesbothy.agent.handle(from theagentsport as for the other run metrics),bothy.billing_mode,bothy.currency,bothy.cost.estimate(true). Use the registry of M1-68 (in the closure through M2-02). i. Runs that the server ends (stale,interrupted,cancel_timeout,agent_disabled,access_revokedwithout a laterfinishRuntransition) get no entry, because 09 4.10 namesrun.finishas the only source of per-model usage; a laterfinishRunwith the same final state changes nothing (note b). Ruling for docs/DECISIONS.md; the loss is part of RK-16 and named in the billing README. -
Per-run consumption and the cost baseline.
CostReportis "Cost of one model in this run" (openapi), 09 4.10 books the usage of the run, and the runner reports the difference per run (09 18.7); the server books the values as given. 03agent_sessions.cost_baseline_microssays the server books only the part above the baseline when a runtime reports session sums, but the API has no way to mark a report as a session sum and the adapter never sees the baseline (AgentSessionomits it, M2-12 note 4). This ticket therefore never subtracts the baseline; M8-06 owns the cost baseline of imported sessions (PLAN row M8-06, RK-75) and the spec gap is in the report. Ruling for docs/DECISIONS.md; update 09 4.10 (Scope). -
Valid sums and levels (10 12.1, 03
cost_entries): an entry is valid when no entry hassupersedes_idequal to itsid. Sums group valid entries bybilling_modeandcurrency;amount_microsis the sum,is_estimateis true when any summed entry is an estimate, token counts are summed per field, never converted.Totals(ctx, TotalsQuery)withTotalsQuery{Level Level; ID ids.ID; From, To time.Time; Channels ChannelFilter; ForDisplay bool}: levelsrun(run_id),thread(thread_id),session(agent_session_id),agent(agent_id),space(space_id),instance(all);FromandToa half-open range onoccurred_at(zero means open);ChannelFilter{All bool; IDs []ids.ID}restricts to the visible channels of the caller (channel_id = ANY), and a filter that is neitherAllnor has IDs returns nothing (fail closed, 03 principle 12); withForDisplaythe display rule of note 8 d hides amounts.Total{BillingMode, Currency string; AmountMicros *int64; IsEstimate bool; Tokens TokenCounts; Entries int64}, ordered by billing mode, then currency. Levels use the copies at booking time; only the thread level follows the thread itself. Ruling for docs/DECISIONS.md: a correction entry repeats the token counts of the entry it supersedes, so token sums do not drop after a reconciliation (state it in the billing README for M2-28). -
getThreadCosts(05 4.12cost.read.thread,rl.read, no audit): a.authorize(actor, cost.read.thread, {kind: thread, id}): no read right or an unknown thread givesnot_foundwith the same status, body and headers (AZ-P4); agents with read through the operator rule are allowed; an app with read on the thread getsforbiddensubject_kind_not_allowedfrom the scope table of M1-28 (05 2.6 has no scope for this action); an app without read gets the samenot_foundas an unknown thread, because visibility is checked first (07 2.10, AZ-M34). b. Read the valid entries withthread_id = $1(index(thread_id)), whatever channel they were booked in, and group them per run and per currency. c. Representation (openapi change in the same commit, owned by this ticket because onlygetThreadCostsuses these schemas; the API is0.xuntil the first release, 06 13 point 1):CostBreakdown: required [thread_id, totals, runs] totals: array of CostTotal # valid sums per billing mode and currency, display rule applied runs: array of RunCost # runs of the thread with at least one valid entry, by run_id ascending CostTotal: required [billing_mode, amount_micros, currency, is_estimate] RunCost: required [run_id, billing_mode, tokens, unpriced] agent_session_id: Uuid # absent when the entries have no session tokens: TokenCounts # always, also for personal costs: array of Money # one per currency; absent when amounts are hidden unpriced: boolean # a valid entry of the run has source unpriced ("Price missing") TokenCounts: required [tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, tokens_cache_write_1h], int64total(oneMoney) andRunCost.costare replaced, because 10 12.1 requires sums per currency, 10 12.2 hides amounts forpersonaland shows tokens, and "Price missing" needs a marker. Nevertriggered_byor any person: in threads only costs per run (20 9). Ruling for docs/DECISIONS.md; update the example ofgetThreadCosts(white label,model-large,USD). d. Display rule (10 12.2, BR-11, D-BR-01): for a run withbilling_mode = personal,costsis absent and its amounts are left out oftotals, unlessbilling.show_personal_estimatesis true;billing_modeandtokensare always present. Modeorganizationalways has amounts withis_estimate("Estimate" until reconciled). A thread without entries answers200with emptytotalsandruns. e. Ruling for docs/DECISIONS.md: runs are ordered byrun_idascending (UUIDv7, the order of the runs), and the list is not paginated because the schema has none (spec gap for very long threads). -
Other cost members.
Run.cost,RunSummary.cost, the run document member/costandAgentSession.costare requiredMoneyof M2-05, M2-10 and M2-12 and carry the stored values in both modes; clients show amounts from them only fororganization(09 5.4 row "Costs"), and amounts that the switchbilling.show_personal_estimatesmay reveal forpersonalcome only fromgetThreadCosts(and latergetCostStats), where the server applies the switch. Ruling for docs/DECISIONS.md; write this rule into 10 12.2 (spec gap: the switch is a server key that clients cannot read). -
listPrices(price.read, owner,rl.read, no audit):authorize(actor, price.read, instance); a person who is not an owner, and an owner's personal token (05 2.3 point 1), get403 forbiddenwithreasonowner_onlyfrom M1-28. All rows, closed ones included, ordered byprovider,model,unit,valid_from,idascending;valid_toabsent while open; nocreated_byin the answer (not inPrice). Ruling for docs/DECISIONS.md: the price table is a small list in the sense of 06 6 point 6 (PriceListhas onlyitems); add it to that list. -
createPrice(price.manage, owner, step-up, audit,rl.admin,Idempotency-Keythrough M1-20): a.authorize(actor, price.manage, instance)first: a person who is not an owner gives403 forbidden(owner_only), any token (agent, app, an owner's personal token) gives403 forbiddenwith the reason M1-28 assigns (owner_onlyorstep_up_not_possible, 05 2.3 point 1, R-AZ-03), an owner without fresh step-up gives401 step_up_required; denials are audited by the authz layer (Audityes). b.domain.ValidatePrice(pointers on the body, codes of M1-18):providerandmodelnon-empty after trimming and passingtextcheck.DisplayText;price_microsnot negative and below the integer bound of 06;currencyof the form^[A-Z]{3}$;valid_to, when present, aftervalid_from(invalid);source_urlan absolutehttpsURL with a host and without user info or fragment (invalid_format);retrieved_atnot afternow(invalid). Ruling for docs/DECISIONS.md: https only and no future retrieval time, because 03 calls the source "public pricing page" and a retrieval in the future is not a fact. c. A row of the sameproviderandmodel(any unit) whose validity overlaps the new one and whose currency differs gives409 conflictwithreasonprice_currency_mismatch(note 4 b needs one currency per price set). Insert with a new UUIDv7 andcreated_by= the actor; an exclusion or unique violation (SQLSTATE23P01or23505) gives409 conflictwithreasonprice_overlap. A concurrent pair of inserts of different units and currencies can both pass the currency check; note 4 b then treats the set as incomplete, so no wrongcomputedamount can result. Ruling for docs/DECISIONS.md: both reasons and this race rule. d. Avalid_fromin the past is allowed: it cannot change acomputedbooking, because such a booking had every needed unit and the exclusion constraint forbids a second row for it (note 4 e covers the others). e. Audit in the transaction (note 14) and201with thePrice. -
closePrice(price.manage, as note 11 a, bodyPriceClose{valid_to}): a. Load the rowFOR UPDATE; unknownprice_idgives404 not_found.valid_tonot aftervalid_fromgives400 validation_failed(invalid, pointer/valid_to). b. The row is already closed: the samevalid_toanswers200with the row and an audit entry withno_change: true(06 2 "No effect"); another value gives409 conflictwithreasonprice_closed. c. The new end must not cut off a booking: when a valid or supersededcomputedentry with the sameproviderandmodel, withoccurred_atat or after the row'svalid_fromand at or after the newvalid_to, has a count above0for the row's unit, answer409 conflictwithreasonprice_in_use(R-DM-23). The partial index(is_estimate, occurred_at) WHERE is_estimateserves this query until reconciliation; after it the superseded entry stays in the table. d. Concurrency: booking holds the rows of its price setFOR SHARE(note 5 e) andclosePricetakesFOR UPDATE, so a close waits for a running booking and then sees its entries, and a booking that waits for a close re-reads the row with its new end (READ COMMITTED) and leaves it out of the set. Test this race (Tests). e. Otherwise setvalid_to(the only column the grant allows, note 2 c), audit, answer200with thePrice. Ruling for docs/DECISIONS.md: the reasonsprice_closedandprice_in_use, and that a closed row is never reopened or moved (a gap is filled by a new row). -
Start file (10 12.3, PLAN row M2-22): a.
internal/billing/domain/startprices/start_prices.json, schemastart_prices.v1:{"schema": "start_prices.v1", "rows": [{"provider", "model", "unit", "price_micros", "currency", "valid_from", "source_url", "retrieved_at"}]}. Rows: the base prices per unit of the five token units for every model in the main price table of the pricing page cited in 10 12.3 (no surcharges, discounts or tiers, 10 12.3), read once during the build;source_urlthat page,retrieved_atthe time of reading in UTC,valid_from = retrieved_at;providerandmodelexactly as the runtimes report them inCostReport(09 18.7: the model identifiers of the runtime; take the identifiers from the model overview of the same documentation site when the pricing page shows only display names, and name that page in the report).Rows()parses with unknown members rejected. b. No loader: the server never reads the file at runtime. Loading it on the host would need a system action (rightsystem, Audityes, likeinstance.bootstrap, R-AZ-15) and a row in 02 3.3, and this ticket may not add an action to 05 (P-11). Until a core-contract change adds one, the owner enters prices withcreatePrice. -
Audit details (10 14 row "Prices": row, source, retrieval time; 19 SEC-AUD-5): action
price.manage, resource kindinstance, attributesprice_id(ID),op(codecreateorclose),valid_from(time),valid_to(time, when set),retrieved_at(time),source_url,no_change(bool, only for a close without change).source_urlis free text, which SEC-AUD-5 forbids unless a ticket extends it: add the value kindauditing.SourceURL(u string) Value, accepted only under the keysource_urland only for an absolutehttpsURL without user info and fragment (validated again inaudit/domain), and the member in 19 SEC-AUD-5. Ruling for docs/DECISIONS.md. -
Configuration. Register
billing.show_personal_estimates(boolean, default from 10 17, chapter 10) inconfigkeys_billing.go; the billingappgets the value through its configuration port at construction. No other key is new; tests set the key through the configuration loader. -
Rights and requirements. No change in
authzcode (only the requirement testTestCostReadThreadRequirement):cost.read.threadisLevel(read)onthread,price.readandprice.manageareOwneroninstancein M1-28's table, none of them deferred. No action here raises the rights version or writesaccess.changed.billingwrites no events: 02 4.7 has no row for it and the module register listsevents: []. -
Rulings for
docs/DECISIONS.md(one row each,D-M2-22-<n>, with reason): notes 2 c; 3; 4 c; 4 e; 5 b; 5 c; 5 f; 5 g; 5 i; 6; 7 (token counts of corrections); 8 c; 8 e; 9; 10; 11 b; 11 c; 12 e; 14. Rules already in the spec (precedence ofcomputed, no conversion, valid sum, display rule, non-overlapping validity) are not repeated there.
Tests (write them first)¶
Unit:
internal/billing/domain:TestValidateReports(empty provider, control and bidi characters in model, negative tokens and amount, value at the integer bound, lower-case and four-letter currency, duplicate provider and model, absent token fields count0);TestPriceSet(valid_fromequal toTincluded,valid_toequal toTexcluded, open end, other model and other case not matched);TestCompute(complete set givescomputedwith the exact amount;tokens_cache_write_1hpriced withcache_write_1h_mtokand never withcache_write_mtok; a needed unit missing givesadapter_reportedwith an amount andunpricedwith0without one; an adapter amount next to a complete set is ignored; zero tokens with a set givecomputed0; a set with two currencies is incomplete; therequestunit is ignored; rounding half up exactly at.5micro; the result at the bound fails);TestAmountProperty(random counts and prices: equal to amath/bigreference with one rounding, never negative);TestValidatePrice(every rule of note 11 b);TestCloseDecision(open row closed, same end no change, other end on a closed rowprice_closed, end not after start invalid);TestSums(two currencies stay apart, two modes stay apart, a superseded entry is left out and its successor counted,is_estimatetrue when any entry is an estimate, token sums);TestDisplayRule(BR-11 unit:personalwithout the switch has nocostsand no total, tokens present; with the switch amounts present;organizationalways with amounts).internal/billing/domain/startprices:TestStartPrices_EveryRowHasSourceAndRetrievedAt(the plan's acceptance test: every row has a non-emptysource_urland aretrieved_at);TestStartPrices_Valid(at least one row; every row passesValidatePrice; only the five token units; one currency per provider and model; no overlapping validity of one provider, model and unit;valid_fromequalsretrieved_at; unknown members rejected).internal/kernel/auditingandinternal/audit/domain:TestSourceURLValue(accepted only undersource_url;http, user info, fragment and relative URLs rejected).internal/authz:TestCostReadThreadRequirement(decide with M1-28's fake facts in the world of 07 11.1): Sam with G-4 on Support/Allgemeinallow; Lotseallow; Sam without a grantnot_found; an app principal with an active installation and areadgrant on Support/Allgemeinforbiddensubject_kind_not_allowed(05 2.6 has no scope forcost.read.thread); Tracker (G-6 only on Support/Eskalation)not_foundlike AZ-M34. No integration case uses an app token: app tokens exist only from M2-24, which is not in this ticket's closure (M1-44,createTokenstep 3), so apps are tested with the authz fakes as in M2-16.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, agent tokens and people from the identity test helpers, the world builder of test/integration for the world of 07 "Examples", fake clock; Robin is the only owner and the credential holder of the agent Assistent (personal) that operates Research/Notizen; Alex has trigger on Support (G-1), whose channel Allgemein is operated by Lotse (organization); Sam has read on Support/Allgemein (G-4) or nothing, per test; prices from createPrice with provider example, model model-large with all five units in USD, model model-small with only input_mtok and output_mtok, model model-eur with all units in EUR, source https://provider.example.com/pricing; runs reach running through the paths of M2-05 to M2-09):
TestMigration_PriceTableandTestMigration_CostEntries: columns, types, nulls and defaults as 03; both checks; every FK; the unique and partial indexes; an overlapping row of the same provider, model and unit fails with23P01, an adjacent one (valid_toequal to the nextvalid_from) passes;btree_gistis installed;bothy_appcan insert and read, can updateprice_table.valid_to, cannot update any other column ofprice_table, cannot update or deletecost_entries; the FK ofagent_session_idexists.TestFinishRunBooks_BR09: Lotse finishes withcostsformodel-large(all counts above0, with anamount_microsthat differs from the price), formodel-unpricedwith an amount, and formodel-nonewithout one: three entriescomputed(the computed amount),adapter_reported(the adapter's amount and currency) andunpriced(0); allis_estimate,billing_mode = organization,occurred_at = finished_at, copies of agent, thread, channel and space;runs.cost_micros,currency,cost_is_estimateas note 5 f;versionraised once; exactly onerun.state_changed, whoserun.costis the booked sum; onebothy.run.costdata point per entry with the four attributes after commit (in-memory metric reader).TestFinishRunPartialSet:model-smallwith cache counts above0and an amount givesadapter_reported; without an amountunpriced.TestFinishRunRepeatBooksNothing: the samefinishRun(completed, costs)again answers200and adds no entry; parallel identical finishes book once.TestFinishRunCostsRules: invalid reports give400 validation_failedwith the pointers of note 3 under/costsand leave run and table unchanged;failedfromqueuedwith a non-emptycostsgives400(invalid,/costs) and the run staysqueued; a finish withoutcostsor withcosts: []books nothing and leavescost_micros,currencyandcost_is_estimateunchanged; before the finish the test sets non-default values (for example1234,EUR,false) with a directUPDATE runsthroughpgtest'sSuperConn, becausepatchRunbelongs to M2-10, which is not in this ticket's dependency closure (if M2-10 is merged on the base, set them withpatchRunon/costinstead); the M2-09 answernot_enabledis gone.TestFinishRunTwoCurrencies:model-largeandmodel-eurin one run give two entries; the run columns hold the first entry's currency only (note 5 f);getThreadCostsshows both currencies, never converted.TestSessionCostSum: two runs in one session raisecumulative_cost_microsby their sums,versionby one each, noagent_session.changed; an entry of another currency is not added.TestBookingsStayRecomputable(R-DM-23): after bookings,closePriceat a later end and a new row from then on, thenRecomputeComputedreports no difference;closePricewith an end before acomputedbooking that used the row gives409 conflictprice_in_useand changes nothing; the same end on a row whose unit had count0in every booking after it passes.TestCloseRaceWithBooking: parallelclosePricewith an end beforenowand a booking atnowon the same set: either the booking iscomputedand the close failsprice_in_use, or the close succeeds and the booking is notcomputed;RecomputeComputedreports nothing.TestGetThreadCosts_Organization: Alex reads a Support thread with three runs:runsbyrun_id,costsper currency,tokenssummed,unpricedtrue only for the run with anunpricedentry,totalsper mode and currency; no JSONnull, no person in the body; the body validates againstCostBreakdown.TestGetThreadCosts_Personal_BR11: Robin triggers Assistent in Research/Notizen, the adapter starts withpersonaland finishes with costs; Robin'sgetThreadCostshasbilling_mode: personal,tokens, nocostsand nopersonaltotal; withbilling.show_personal_estimateson (a second server instance with that configuration on the same database) the amounts appear withis_estimate.TestGetThreadCosts_SupersededExcluded: areconciledentry inserted throughbillingtest.InsertEntrywithsupersedes_idreplaces the estimate in every sum; itsis_estimate = falsemakes the sum non-estimate only when no estimate remains.TestGetThreadCosts_Rights_AZP4: Sam without read gets404with the same status, body and headers as for an unknown thread id; Sam with G-4 gets200; Lotse's agent token gets200; an empty thread gives200with empty lists.TestTotals_Levels: entries in two currencies and both modes across two threads, two channels, two spaces and two agents:Totalsper run, thread, session, agent, space and instance equal hand sums per mode and currency; a period cuts atFrominclusive andToexclusive; a channel filter keeps only listed channels; an empty filter returns nothing;ForDisplayhidespersonalamounts unless the switch.TestListPrices: Robin gets every row in the order of note 10, a closed row withvalid_to, an open one without; Alex gets403 forbiddenowner_only; Robin's personal API token gets403.TestCreatePrice:201with thePrice; one audit allow entry with the attributes of note 14; Robin without fresh step-up gets401 step_up_required; Alex gets403and a deny entry; an overlap gives409 conflictprice_overlap; another currency overlapping the set gives409 conflictprice_currency_mismatch; each rule of note 11 b gives400 validation_failedwith its pointer; a replay with the sameIdempotency-Keyanswers the stored201withIdempotent-Replay: trueand inserts nothing.TestClosePrice:200and an audit entry; the same end again200withno_change: true; another end409 conflictprice_closed; an unknown id404; an end not after the start400.TestAZP1_CostRoutes: the four operations are registered with their catalogue actions and classes and the server starts.
Contract: in the integration tests the responses of getThreadCosts, listPrices, createPrice, closePrice and finishRun validate against CostBreakdown, PriceList, Price, Run and Problem; problems carry code and, where named above, reason.
Red team focus of the milestone (PLAN 8 "Red team": abuse of a personal subscription, confused deputy over the server API) is covered by TestGetThreadCosts_Personal_BR11, TestGetThreadCosts_Rights_AZP4, TestCreatePrice (non-owner, token, step-up) and TestFinishRunRepeatBooksNothing; name them in the red team request together with the question whether any surface leaks personal amounts when the switch is off.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/billing/... ./internal/runs/... ./internal/agents/... ./internal/kernel/... ./internal/authz/... ./internal/audit/... ./internal/wire/... ./migrations/... ./test/integration/...make migrations-lockafter creating the migrations, thenmake migrations-lintmake codegen-check,make arch,make register-check,make api-lint,make api-checks,make api-tablesmake config-referencethenmake config-doc-checkmake docsmake check
Acceptance criteria¶
-
getThreadCosts,listPrices,createPriceandclosePriceare enabled and registered; AZ-P1 passes. - The migrations create
price_tableandcost_entriesexactly as 03 4.9 with the exclusion constraint, the indexes and the grants of note 2. - BR-09: a booking with a price gives
computed, without a price but with an adapter amountadapter_reported, without bothunpricedwith0;computedwins over the adapter amount; every entry is an estimate with the run's actual mode and the copies of note 5 d. - A repeated
finishRunnever books twice; invalid reports and reports for a run that never started give400and change nothing. - Sums per run, thread, session, agent, space and instance count only valid entries and stand per billing mode and currency without conversion.
- BR-11 (server part):
getThreadCostsgives no amounts forpersonalruns unlessbilling.show_personal_estimatesis on, and always the mode and the token counts. -
getThreadCostswithout read right gives the same404as an unknown thread (AZ-P4). - Price changes need an owner with step-up, are audited with row, source and retrieval time, never overlap, never change a used row except its end, and never break the recomputation of a
computedbooking (R-DM-23). - Every row of the start file has source and retrieval date (test).
-
bothy.run.costis recorded per booked entry after commit with the attributes of signals.md 4.3. - No JSON
nullin any response of this ticket. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
billing(port, tables, booking rules, valid sum, display rule, the loss of note 5 i, the note for M2-28 of note 7),runs(booking in the finish, cost columns, metric),audit(value kindsource_url) andagents(AddCost) updated. - Rulings of note 17 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 06, 09, 10, 19 andopenapi.yamlof the Scope made in the same change. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least: the baseline of note 6, the spec gap of note 13 b (loading the start file on the host needs a system action (right
system, Audityes, likeinstance.bootstrap, R-AZ-15) in 05 4.12 and a row in 02 3.3; until a core-contract change adds it, the owner enters prices withcreatePrice), the pages used for the start file and the time they were read, and every difference between the names of this brief and the merged code.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.