M2-28: Cost reconciliation as its own app¶
Status: ready | Depends on: M2-22, M2-24 | Milestone: M2
Goal¶
After this ticket the estimated costs of agent runs can be replaced by the amounts of the provider's invoice data without the provider's admin key ever reaching the server. A separate app in plugins/cost-reconciler (its own process, default off, no inbound port) reads the admin key from its own secret provider, fetches daily cost buckets per provider workspace, distributes each day's amount over the estimates of the agents mapped to that workspace (in proportion to the estimates, the rounding remainder to the largest one, anything without a run reported as not assigned, BR-10) and submits the result with an app token that carries only the scope costs:reconcile (submitCostReconciliation, action cost.reconcile.submit, no step-up). An owner can do the same by hand with step-up (reconcileCosts, action cost.reconcile); app, agent and personal tokens are refused there (R-AZ-03). Both ways store new reconciled entries next to the estimates, never change or delete an existing entry, and apply them through the same job billing.reconcile, which brings runs and agent_sessions (amounts and the estimate flags) up to date. Why: the estimate from tokens and a price table misses surcharges, discounts and price tiers (10 12.1 to 12.3, RK-16), and an admin key of the provider organisation can manage members and keys, so a break-in into the chat server must not reach it (10 12.4, ADR 0055, RK-17, baseline B10 and I5).
Read first¶
docs/spec/10-backend-policy.md: 1 "Purpose" (the server knows no credentials), 3 "Invariants" (I5), 12.1 "Recording" (one entry per model at the end of a run, all entries estimates until a reconciliation replaces them, no currency conversion), 12.2 "Display" (amounts only fororganization, the label "Estimate"), 12.3 "Price table", 12.4 "Reconciliation with billing" (all five steps and the paragraph "Authorization"), 14 "Audit trail" (row "Cost reconciliation"), 15 "Tests" (BR-10, BR-13), 16 (E-BR-07), 17 "Configuration values of this chapter" (reconciler.min_request_interval,reconciler.lookback,reconciler.workspace_map).docs/adr/0055-cost-reconciliation-as-its-own-app-admin-key-never-in-the-server.md(all, especially "Decision Outcome" points 1 to 5 and "Confirmation"); ADR 0022 (extensions outside the server), 0024 (backend policy), 0011 (secret provider), 0013 (spec first), 0015 (errors, idempotency, pagination), 0017 (one authorize function), 0020 (step-up for admin actions), 0021 (audit), 0049 (action catalogue), 0053 (no JSONnull), 0044 (places of the working name).docs/spec/05-actions.md: rule 1.1, rule 1.2 (its exception for the estimate list, R-AZ-28), rule 1.9 (maintenance layer, effectwrite), 2.3 "Effective level" point 1 (owner rights only in a session; a personal token of an owner has none), 2.4 'Columns "Owner", "Step-up", "Audit", "Permissions"', 2.5 "Decision", 2.6 "Scopes for apps" (rowcost.reconcile.read,cost.reconcile.submit, and the sentence that agents lackcosts:reconcile), 2.7 "Effect (effect)", 3 "Rate-limit classes" (rl.admin,rl.read), 4.12 rowscost.read.thread,cost.read.aggregate,cost.reconcile,cost.reconcile.read,cost.reconcile.submit; R-AZ-03, R-AZ-04, R-AZ-28 (the read action for the estimates, D-W-104).docs/spec/07-permissions.md: 2.10 "Apps" (level plus scope,scope_missinggives403), 4.1 (reasonsowner_only,scope_missing,subject_kind_not_allowed,step_up_required,step_up_not_possible,app_not_installed), 4.2 "Flow" (requirement before step-up, audit of denials), 7.4 "Query pattern" rule 1 (writes are outside the visibility lint; the estimate list ofcost.reconcile.readhas no visible-channel predicate, R-AZ-28).docs/spec/03-data-model.md(core contract, read only): 4.9 "Costs (billing)" withprice_tableandcost_entries(every column, the partial unique index onsupersedes_id, the indexes, "Valid total = entries without a successor", personal amounts are information only);runs(cost_micros,currency,cost_is_estimate,version,billing_mode_expected);agent_sessions(cumulative_cost_micros,cost_baseline_micros,currency,cost_is_estimate,version); 6.3 "Lock order"; R-DM-23.docs/spec/02-architecture.md: 4.2 (modulebillingownsprice_tableandcost_entries), 4.3 (ports:billingCostswith "posting, correction, totals"), 4.4 (billingmay use nothing;runsmay usebillingandagents), 4.5 (kernel/txn,kernel/auditing,kernel/authorize,kernel/measure), 4.6 (rowbillingwithcost.reconcile.read), 6 "Code layout" (module layout,test/**, the list of places with the working name), 8.1 "Flow", 8.2 "Lock order", 8.3 "In-Tx events", 12.1 "Usage" (job arguments only IDs), 12.2 (queuedefault), 12.3 rowbilling.reconcile("never periodic"), 12.4 "Rules for jobs", 13 "Configuration loading" point 3 (*_refkeys).docs/spec/06-api.md: 4 point 2 (the exception forlistCostEstimates, R-AZ-28), 5 "Errors" (closed code list,errors[],reason), 6 "Pagination" (cursor,limit,{items, next_cursor}), 7 "Idempotency" (POST withIdempotency-Key: replay,409 request_in_progress,422 idempotency_key_reuse; stored and not stored answers), 16 group "Stats" (rowsreconcileCosts,submitCostReconciliation,listCostEstimates,getThreadCosts,getCostStats).docs/spec/11-extensions.md: 1 "Principles", 2 "Agent or app", 3 "App manifest", 4 "Installation, update, uninstallation", 5 "Scopes and grants" (rowcosts:reconcile), 6 "Isolation and limits" (apps never run step-up actions), 10 "Ready-made integrations as examples" (ownVERSIONandCHANGELOG.md, fakes with fixtures from the provider documentation, no release component).docs/spec/08-identity.md: 6.2 "Step-up flow", 12 "API tokens for agents, apps and tools".docs/spec/16-operations.md5.2 "Secret provider (ADR 0011)" (schemesfileandenv).docs/spec/19-security.md: SEC-SEC-1 to SEC-SEC-4, SEC-AUD-5 (audit_details.v1), SEC-NET-4 (no redirects), SEC-LOG-1 to SEC-LOG-7, SEC-T10.docs/spec/23-quality.md: 3 "Component contract" (Python project underplugins/), "Documentation in code" (ruffD, conventiongoogle), 11 (quality.sonar.new_coverage_min_percent).docs/api/openapi.yaml: operationsreconcileCosts,submitCostReconciliation,listCostEstimates,getThreadCosts,getCostStats,installApp,validateManifest,setAppScopes,uninstallApp,createToken; schemasCostReconcile,ReconcileEntry,CostReconcileResult,CostEstimate,CostEstimatePage,CostBreakdown,Money,AppScope,AppManifest,AppInstall,Problem(x-problem-codes),FieldError; parametersIdempotencyKey,Cursor,Limit; responsesBadRequest,Unauthorized,Forbidden,Conflict,UnprocessableEntity,TooManyRequests,Error. No AsyncAPI channel and no webhook event carries costs (docs/api/asyncapi.yaml,docs/api/webhooks.md).docs/operations/configuration.md: section 2 (duration format,secret_ref), section 4 rowsreconciler.*,ratelimit.rl.admin.per_minute,ratelimit.rl.admin.burst,auth.step_up_max_age,auth.dpop_required_for,jobs.queue.default.max_workers,database.tx_retry_max,http.max_json_body_bytes,sync.idempotency_key_ttl,audit.deny_aggregate_window,sdk.http_timeout.docs/observability/signals.md: 4.3bothy.run.cost(attributebothy.cost.estimate; not recorded by this ticket, note 3i), the job metricsbothy.job.durationandbothy.job.queue, the spanjob <bothy.job.kind>, the spantxn.subscribe <Subscriber>.docs/security/threat-model.md: T-PL-01, T-TK-02.docs/RISKS.md: RK-16, RK-17.docs/DECISIONS.md: D-BR-02, D-K-D28, D-W-21, D-W-100, D-W-103, D-W-104 (cost.reconcile.read, R-AZ-28), section 10 (format of the rows).docs/OPEN-QUESTIONS.md: no question applies to this ticket (checked).docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal with P-18, Scope "costs and reconciliation" and ADR 0055, Exit criteria 2 and 10, Test strategy, Red team "exceeding the scopes of an app", Risks "the cost estimate differs from the invoice"); rows M2-22 (booking, price table,getThreadCosts), M2-24 (apps, installation, scopes, app tokens), M2-12 (agent sessions), M2-27 and M2-29 (the neighbours; the Python SDK is not in this ticket's closure), M2-28 (this ticket); the introduction of block "reference integrations" (no test calls a real service; fakes with fixtures from the provider documentation); M4-28 and M6-05 (console and statistics).- Dependencies: the header names M2-22 and M2-24. Implied through them: M2-12 (through M2-22, D-W-103; the job updates
agent_sessions, 10 12.4 point 4), M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02 and the M1 tickets M1-01 to M1-29, M1-31 to M1-37, M1-44 to M1-46, M1-48 to M1-56, M1-60 to M1-66, M1-68, M1-69 and M1-71. M1-70 is not in the closure and is only touched when it is already merged; M1-69 and M1-71 are in the closure and therefore merged before this brief starts (Design note 15). - M1 briefs whose interfaces this ticket uses: M1-05 (module layout,
architecture/modules.yaml), M1-06 (make register-check), M1-07 and M1-08 (configuration schema ininternal/wire,make config-doc-check), M1-12 (internal/platform/pg/pgtest), M1-13 (txn.UnitOfWork,txn.Publish,txn.Subscribe, the golden file of registrations,CheckTxnDirections), M1-14 (internal/platform/jobs:Args,Handler,Register,HandlerOptions,Inserter.InsertTx,ValidateArgsType), M1-16 (job adapter patterninternal/<module>/adapters/jobs), M1-17 (api/operations.yaml, strict server, route table), M1-18 (StageValidatewith theFieldErrorcode vocabulary, problem writer withreason), M1-19 (rl.admin), M1-20 (Idempotency-Key,Idempotent-Replay), M1-24 (auditing.Recorder.Record,auditing.Entry,AttrswithTime,Int,Code), M1-25 (registration duty), M1-28 (authorize.Authorizer.Authorize,authorize.Resource, requirementOwnerforowner,AppKindfor "app with scope costs:reconcile",Any(Owner, AppKind)for "owner or app with scope costs:reconcile" (R-AZ-28), the app scope table, decision to error mapping), M1-35 (step-up), M1-44 (API tokens), M1-46 (test of places with the working name,tools/ci/tests/test_product_name_places.py). - Code on the base branch (merged names win, D-W-100):
services/server/internal/billing(M2-22),internal/apps(M2-24),internal/runs(M2-05, M2-09),internal/agents(M1-51, M2-07, M2-12),internal/authz,internal/kernel/{authorize,auditing,txn,ids,clock,errs,measure},internal/platform/{jobs,pg,pg/pgtest},internal/wire(configkeys_*.go, the txn golden file),api/operations.yaml,architecture/modules.yaml;plugins/README.md;.semgrep/bothy-go-no-model-credentials.yaml; the rootMakefile(component discovery of Python projects underplugins/). Read merged M1 code withgit show main:<path>if the base branch is older.
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.
Product requirements implemented here:
- The reconciliation is an app outside the server, off by default, with its own process, its own configuration and its own secret provider; the provider's admin key exists only there; the app needs no inbound port and talks to the server only through the public API (10 12.4, ADR 0055 points 1 and 4, 11 1, PLAN P-18).
- The server never receives, reads, stores or forwards a provider key: no server configuration key, table column, job argument or code path for it, and no environment read of provider credentials (
.semgrep/bothy-go-no-model-credentials.yaml; ADR 0055 "Confirmation"; B10, I5; PLAN row "the server stores no vendor key (canary)"). cost.reconcile(reconcileCosts): instance, owner only, step-upyes, audityes,rl.admin, effectwrite; agent, app and personal tokens are refused (05 4.12, 2.4, R-AZ-03; 07 4.2).cost.reconcile.submit(submitCostReconciliation): instance, only an app with the granted scopecosts:reconcile, no step-up, audityes,rl.admin; it only addsreconciledentries to existing runs and never changes or deletes another entry (05 2.6 and 4.12, ADR 0055 point 3 and "Confirmation", 10 12.4 "Authorization").- A reconciled entry has
source = 'reconciled',is_estimate = false,reconciled_atandsupersedes_idon the entry it replaces; every entry has at most one successor; the valid sum is the sum of the entries without a successor (03cost_entries, 10 12.4 point 4). - Both ways apply their entries through the same job
billing.reconcilein queuedefault, never periodic; job arguments carry only IDs; the job can run again without a second effect (02 12.1, 12.3, 12.4; PLAN row M2-28). - After the job,
runs.cost_micros,runs.cost_is_estimate,agent_sessions.cumulative_cost_microsandagent_sessions.cost_is_estimatereflect the valid entries; amounts of different currencies are never converted (10 12.1 and 12.4 point 4). - The app distributes each day's amount of a workspace over the estimates of the mapped agents in proportion to the estimates; the sum equals the actual amount; the rounding remainder goes to the largest entry; amounts without a matching run are reported as not assigned (10 12.4 point 3, ADR 0055 point 2, BR-10).
- The app asks the provider at most once per
reconciler.min_request_interval, for the lastreconciler.lookbackdays, and maps workspaces to agents throughreconciler.workspace_map(10 12.4 points 1, 2 and 5; 10 17). - The audit entry of a reconciliation holds the period, the numbers of entries and the sums per currency, never a secret or free text (10 14; 19 SEC-AUD-5, SEC-SEC-3).
- Errors follow 06 "Errors" with codes from
x-problem-codes; both operations acceptIdempotency-Keywith the behaviour of 06 "Idempotency". - Amounts of runs in mode
personalare information, never invoice (03cost_entries, 10 12.2); they are never reconciled (Design note 4). cost.reconcile.read(listCostEstimates): instance, an owner in a session or an app with the granted scopecosts:reconcile, no step-up, auditno, effectread,rl.read; per run it returns only run, agent, provider, currency, the estimate and whether it is reconciled, of modeorganization, never a channel, a thread or content, and it is not filtered by visible channels; agents, personal tokens and people who are not owners get403 forbidden(05 rule 1.2, 2.6, 2.7, 4.12 and R-AZ-28; 06 4 point 2).
Scope¶
All server paths are under services/server/ unless noted. This ticket creates no make target and no migration.
Create (server):
internal/billing/domain/reconcile.go: pure rules on domain types (the domain may not import the port, 02 5), standard library andkernel-coreonly: typesRequest,RequestEntry,Entry(one row ofcost_entries),Share,Violation(pointer and code),UnmatchedReason; functionsValidate(req Request) []Violation(Design note 3b),Match(valid []Entry, req Request, e RequestEntry) (matched bool, noop bool, reason UnmatchedReason)(note 4),Split(amount int64, valid []Entry) []Share(note 5). The app layer converts the port DTOs.internal/billing/app/reconcile.go: the use caseReconcilefor both actions (note 3) and the outgoing portReconcileScheduler(inserts the job).internal/billing/app/apply.go: the job use caseApplyReconciliation(ctx, runIDs []ids.ID) error(note 6).internal/billing/adapters/postgres/queries/reconcile.sql: take the per-run advisory locks, read the valid entries of runs in a period, insert reconciled rows, totals per run, totals per session (notes 3 and 6); plus the query of the read operation of note 12.internal/billing/adapters/jobs/: argsReconcile{RunIDs []ids.ID}with kindbilling.reconcile, its handler (queuedefault, no periodic registration), theReconcileSchedulerimplementation withjobs.Inserter.InsertTx.internal/runs: subscriberOnCostsReconciledininternal/runs/app(or the existing subscriber file) and the query that sets the cost totals of runs (note 7).test/reconcile/: the end-to-end test of note 14 (reconcile_e2e_test.go), its fake cost API, andREADME.md.
Create (app, all under plugins/cost-reconciler/):
pyproject.toml,uv.lock,README.md(English, like the other READMEs underplugins/),VERSION,CHANGELOG.md,manifest.json.- Package
cost_reconciler/:__init__.py,__main__.py(command line),config.py,secretref.py,amounts.py,costapi.py,server.py,distribute.py,reconcile.py(one pass),logs.py,uuid7.py(note 11). tests/:fakes.py(fake cost API and fake server on127.0.0.1port 0),fixtures/(cost report pages), and the tests of the section "Tests".
Change:
internal/billingport:Costs.Reconcile(ctx context.Context, in ReconcileInput) (ReconcileResult, error)withReconcileInput{Action authorize.Action; Provider string; PeriodStart, PeriodEnd time.Time; Entries []ReconcileEntry},ReconcileEntry{RunID ids.ID; AmountMicros int64; Currency string},ReconcileResult{Reconciled int; UnmatchedRunIDs []ids.ID}; In-Tx eventCostsReconciled{Runs []RunCostTotal; Sessions []SessionCostTotal}withRunCostTotal{RunID ids.ID; AmountMicros int64; Currency string; IsEstimate bool}andSessionCostTotal{SessionID ids.ID; AmountMicros int64; Currency string; IsEstimate bool}; the read method of note 12. Merged names of M2-22 win where they differ only in name.internal/billing/adapters/httpapi: the strict-server methods forreconcileCosts,submitCostReconciliationand the read operation of note 12.internal/agentsportSessions:ApplyCostTotals(ctx context.Context, totals []SessionCostTotal) errorwith the agents' own type (no import ofbilling), unless M2-22 already added an equivalent that sets totals; then use that one.internal/wire: register the job handler for the rolesworkerandall, the scheduler for all roles,txn.Subscribeforruns.OnCostsReconciled; regenerate the golden file of In-Tx registrations; the test of note 3k.internal/authz: tests of the requirements ofcost.reconcile,cost.reconcile.readandcost.reconcile.submit; the entry of the read actioncost.reconcile.read(requirementAny(Owner, AppKind)for the 05 text "owner or app with scopecosts:reconcile", scope table rowcosts:reconcile; R-AZ-28), unless the base branch has it already.architecture/modules.yaml:cost.reconcile.readunderbillingas 02 4.6 lists it, unless it is there already (make register-check).api/operations.yaml: movereconcileCosts,submitCostReconciliationandlistCostEstimates(note 12) fromlatertoenabled; rerun the route generator.docs/api/openapi.yaml: the changes of note 10.docs/spec/10-backend-policy.md: 12.4 (how the server stores, matches and applies; note 16) and 17 (the new app keys of note 11c);docs/spec/02-architecture.md: 4.3 rowsbillingandruns(the In-Tx event), 12.3 rowbilling.reconcile(note 6);docs/operations/configuration.mdregenerated withmake config-reference.docs/adr/0055-cost-reconciliation-as-its-own-app-admin-key-never-in-the-server.md: add the names of the tests of this ticket to "Confirmation" without changing the decision.- READMEs:
internal/billing/README.md(port, event, job, the request and job split; replace "periodic, if enabled" with "never periodic" as 02 12.3 says),internal/runs/README.md,internal/agents/README.md,test/reconcile/README.md,plugins/README.md(row forcost-reconciler/),plugins/cost-reconciler/README.md. services/server/CHANGELOG.md(Unreleased),plugins/cost-reconciler/CHANGELOG.md,docs/DECISIONS.mdsection 10 (rowsD-M2-28-<n>).- As Design note 15 says: the root
Makefile(one line oftypecheck, only under its condition),test/canary(M1-69, in the closure), the visibility lint exception list (M1-70, only if it is merged).
Out of scope¶
- Booking of costs at the end of a run, the price table,
getThreadCosts,listPrices,createPrice,closePrice, the label "Estimate" and BR-09, BR-11 (M2-22). - App manifests,
installApp,validateManifest, scopes inauthz, app tokens fromcreateToken,setAppScopes,uninstallApp(M2-24); this ticket only uses them. - The table
agent_sessionsand the portagents.Sessionsitself (M2-12). - Cost statistics
getCostStats(M6-05); the console views for costs and a manual reconciliation form (M4-28 and the console tickets of M6). - The Python SDK (M2-29, not in this ticket's closure); the app uses the public REST API directly.
- BR-13 for the adapters (M2-33) and the runner's key handling (M2-34).
- The canary test across all outputs SEC-T10 (M1-69), the SQL visibility lint (M1-70) and the Schemathesis contract tests (M1-71); this ticket only extends them as note 15 says (M1-69 and M1-71 are in the closure, M1-70 only if it is merged).
- The maintenance mode layer of 05 rule 1.9 (M7, P-17); the reconciliation actions have effect
writeand fall under it when it comes. - A release component and packaging of the app (18, M7 release chain); like the reference apps of 11 10, operators build it from the repository.
- Reconciliation apps for other providers (ADR 0055: each is another app) and costs of a priority tier, which the provider's cost endpoint does not report (ADR 0055, "Consequences").
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-28-cost-reconciliation; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(billing): ...,feat(runs): ...,feat(cost-reconciler): ...,docs(api): ...) 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, Go modules and Python packages pinned in
uv.lock. - 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>. - No test, script or manual step calls the provider's real API or uses a real admin key; the provider side is a fake on
127.0.0.1with fixtures written after the provider documentation cited in 10 12.4. A live run against a real organisation needs an admin key and an external account and is the owner's decision, outside this ticket. - Admin keys and app tokens in tests are random canary values created at run time; no file in the repository contains a key-like value (
make gitleaks). No model runs and no agent runtime starts in any test. - The server gets no configuration key, column, job argument, log field or code path that could hold a provider key; never add an exception to
.semgrep/bothy-go-no-model-credentials.yaml. - The app's Python dependencies are pinned in its
uv.lockand passmake licenses; prefer the standard library. Its virtual environment and caches stay inside the worktree or amktemp -dfolder. - Changes to code of merged tickets (M2-22, M2-24, M2-12, M2-09, M1-28) stay within the Design notes, each with a test; no other behaviour of those tickets changes.
Design notes¶
-
Preconditions. Check them before writing any code; if one fails, stop with the verdict named. a. Read action for the estimates (core contract, R-AZ-28 under D-W-104): 05 4.12 row
cost.reconcile.read(resourceinstance, right "owner or app with scopecosts:reconcile", Owner, Step-up, Audit and Permissionsno, classrl.read, effectreadby 2.7), 05 2.6 (scopecosts:reconcile), rule 1.2 (the list is evaluated on the instance without a visible-channel predicate), 02 4.6 (modulebilling) and indocs/api/openapi.yamlthe operationlistCostEstimateswith the schemasCostEstimateandCostEstimatePage; build it as note 12 with exactly these values. A base branch whosedocs/spec/actions.yamllackscost.reconcile.read(withapp_scope: costs:reconcile) or whosedocs/api/openapi.yamllackslistCostEstimateshas not taken over that spec state: stop withVerdict: BLOCKED spec state: cost.reconcile.read (05 4.12, R-AZ-28) or listCostEstimates missing on the base branch. b.agent_sessionsand the portagents.Sessionsof M2-12 (implied through M2-22, D-W-103) exist on the base branch; if not, stop withVerdict: BLOCKED dependency M2-12 (agent_sessions) not merged. c. The mergedcost_entrieshas what 03 defines and this ticket uses:is_estimate,sourcewithreconciledin its check,reconciled_at,supersedes_idwith its foreign key and the partial unique index(supersedes_id) WHERE supersedes_id IS NOT NULL, the indexes(run_id),(thread_id)and(agent_id, occurred_at). A missing part is a deviation of M2-22 from 03: stop withVerdict: BLOCKEDper the D-W-100 boundary. -
Shape across modules.
billingmay use no other module (02 4.4), but the totals live in tables ofrunsandagents. So: the request writes onlycost_entries(owned bybilling) and inserts the job; the job readscost_entries, computes totals and publishes the In-Tx eventbilling.CostsReconciled;runs(which may usebillingandagents) subscribes, updates its own rows and hands the session totals toagents.Sessions.ApplyCostTotals. Every edge points downward;CheckTxnDirectionsof M1-13 accepts it. Ruling for docs/DECISIONS.md, with 02 4.3 updated (rowbilling"Emits (in-tx)"CostsReconciled; rowruns"Subscribes (in-tx)"billing.CostsReconciled). Reason: the same pattern asagents.AgentDisabled; no new module edge. -
Request use case
Costs.Reconcile(both operations call it with their action), inside the request's unit of work: a.authorize(actor, action, Resource{Kind: instance})first. If the generated route stage of M1-28 already authorizes instance-level actions, do not call it twice (follow the merged code). Denials are audited by the authz layer (audityes, R-AZ-04). b. Rules after the schema check ofStageValidate, all violations at once as400 validation_failedwitherrors[](codes from the M1-18 vocabulary):period_startnot beforeperiod_endgivesinvalidat/period_end; arun_idthat occurs again givesinvalidat/entries/<i>/run_idof the later occurrence. The schema checks of note 10 givetoo_smallfor a negativeamount_micros,pattern_mismatchfor a currency that is not three upper-case letters andtoo_shortfor an emptyprovider. c. Lock: for each distinct run ID of the request, sorted ascending in Go and one call per ID,SELECT pg_advisory_xact_lock(hashtextextended('billing.reconcile:' || $1::text, 0)). This is the domain step of 03 6.3 rule 2, the same pattern as M1-32 and M3-03. No row lock:bothy_apphas onlySELECT, INSERToncost_entries(M2-22 note 2c), andFOR UPDATE/FOR SHAREneed theUPDATEprivilege. The partial unique index onsupersedes_idstays the backstop. d. In a new statement after the lock (READ COMMITTED sees what a concurrent reconciliation committed meanwhile), read the valid entries (no other entry hassupersedes_idon them) of these runs withoccurred_at >= period_start AND occurred_at < period_end. e. Match each entry in request order (note 4). Unmatched run IDs go tounmatched_run_idsin request order. f. For each matched run that is not a no-op: compute the shares (note 5) and insert one row per valid entry in the period: new UUIDv7id, the copiesrun_id,agent_id,thread_id,channel_id,space_id,agent_session_id,billing_mode,provider,modeland all token columns of the replaced entry,occurred_atof the replaced entry (the day stays the same),amount_microsthe share,currencyof the request entry,is_estimate = false,source = 'reconciled',reconciled_atfromkernel/clock,supersedes_idthe replaced entry. Only inserts; noUPDATEand noDELETEoncost_entriesanywhere in this ticket. g. If at least one run got new rows:InsertTxone jobbilling.reconcilewith these run IDs in ascending order. Both actions insert the same kind with the same args type. h. Audit allow entry in the same transaction throughauditing.Recorder.Record: actioncost.reconcileorcost.reconcile.submit, resource kindinstancewithout ID,Attrs:period_startandperiod_end(Time),entry_count,reconciled_countandunmatched_count(Int), and one membertotal_micros_<currency in lower case>(Int) per currency with the sum of the amounts of the matched entries. Nothing else; no provider name, no run IDs (10 14: "period, number of entries, sums per currency"). Ruling for docs/DECISIONS.md: the same details forcost.reconcile, which 10 14 does not list separately. i. This ticket records nobothy.run.costpoint for reconciled entries:billingmay use no other module (02 4.4) and so cannot set the required attributebothy.agent.handle(signals.md 4.3;cost_entriesholds onlyagent_id, 03), the metrics port that M2-22 adds forbothy.run.costbelongs toruns(M2-22 note 5 h), and metrics are recorded only after the unit of work committed, never inside it (M2-05 note 11). Name the missingbothy.cost.estimate = falsepoints as a spec gap in the report. j. Answer200withCostReconcileResult{reconciled, unmatched_run_ids}:reconciledcounts the matched entries (written or no-op), soreconciledplus the number of unmatched IDs equals the number of entries;unmatched_run_idsis[]when empty (ADR 0053). k. A test ininternal/wireasserts that the server's configuration schema has no key underreconciler.and no key whose name containsadmin_key(thereconciler.*keys have the place "Reconciliation app", 10 17). -
Matching (Ruling for docs/DECISIONS.md, documented in 10 12.4). A request entry for run R is matched when R has at least one valid entry in the period and all of them have
billing_mode = 'organization',providerequal to the request'sproviderandcurrencyequal to the entry'scurrency. Otherwise R is unmatched: no cost entry at all (also an unknown or still active run;billingcannot and need not tell them apart), no valid entry in the period, modepersonal(amounts are information, never invoice, 03), another provider, another currency or mixed currencies (no conversion, 10 12.1). A matched run is a no-op when all its valid entries in the period arereconciledand their sum equals the amount: nothing is written and it counts as reconciled. Reason: the app submits the same days again on every pass withinreconciler.lookback; the second pass must not grow the chain. A different amount supersedes the reconciled entries (a correction, "a correction entry replaces an earlier one", 03). Entries of R outside the period stay as they are (a run with turns on two days is reconciled per day). -
Split within a run (Ruling for docs/DECISIONS.md). One request entry carries one amount per run, but a run has one entry per model and turn (10 12.1, 09 4.10). The amount A is split over the run's valid entries in the period in proportion to their current amounts: share = floor(A times amount_j divided by the sum); the remainder goes to the entry with the largest amount, ties to the smallest
id; if all amounts are0, the whole of A goes to the entry with the smallestid. Every valid entry in the period gets exactly one successor, also with share0, so no estimate stays valid next to a reconciled amount. Compute without int64 overflow (math/bigormath/bits). Reason: the same rule as BR-10 one level down; the per-model attribution is a proportion, as the ADR states for the day. -
Job
billing.reconcile(02 12.3, queuedefault, River defaults, never periodic): the handler ininternal/billing/adapters/jobscallsApplyReconciliation(ctx, runIDs)in one unit of work: a. For each run ID of the job in ascending orderSELECT pg_advisory_xact_lock_shared(hashtextextended('billing.reconcile:' || $1::text, 0)). With the exclusive lock of note 3c this orders a job against a concurrent request: a request that commits new rows after the job read the totals had to wait for the job, and its own job runs afterwards. b. Per run: amount = sum of the valid entries (all periods), currency = their currency (one currency per run after note 4; if a run's valid entries mix currencies, leave that run unchanged, log the run ID without amounts and go on), is_estimate = any valid entry hasis_estimate = true. c. Peragent_session_idof these runs: the same over all valid entries of the session (query bythread_idandagent_session_id, index(thread_id)). d.txn.Publish(ctx, billing.CostsReconciled{Runs, Sessions}), both lists in ascending ID order. e. Ruling for docs/DECISIONS.md, with 02 12.3 updated ("the request stores thereconciledentries; the job applies them to runs and sessions"): the request stores the entries because job arguments carry only IDs (02 12.1;ValidateArgsTypeof M1-14 allows onlyids.ID,[]ids.IDandint64) and 03 has no other table that could hold a submission; the job applies them. The job reads its state again on every run, so a repeated run has no second effect (02 12.4 rule 1). -
Totals in
runsandagents.runs.OnCostsReconciledupdates, in ascendingid,cost_micros,currencyandcost_is_estimateof each run whose values differ and raisesversionby one for each changed row (03:version"rises with every stored change"); unchanged rows are not touched. Then it converts the session totals to the agents' type and callsagents.Sessions.ApplyCostTotals, which setscumulative_cost_microsandcost_is_estimatewhere they differ, in ascendingid, raisesversionby one for each changed row (03 principle 10, as M2-22 Design note 5 g does when it books; an adapter whose nextagent_session.reportthen gets412 version_conflictreceives the row incurrent, 06 5) and leavescost_baseline_microsalone. No durable event is written: 05 section 6 has no event for costs; clients see the reconciled amounts on the next read (getThreadCosts,getRun; spec gap reported). -
Authorization (07 4.2 order: requirement before step-up):
reconcileCosts: an owner in a session with a step-up younger thanauth.step_up_max_ageis allowed; an owner without a fresh step-up gets401 step_up_required; an app token (also withcosts:reconcile), an agent token and an owner's personal token fail the requirementOwner(owner rights need a session, 05 2.3 point 1) and get403 forbiddenwithreason: owner_only; a non-owner person gets the same. This is how R-AZ-03 holds here: no token reaches the step-up step. Nothing is written, no job is inserted, the denial is audited.submitCostReconciliation: an app withcosts:reconcileingranted_scopesis allowed; an app without it gets403 scope_missing; a person (also an owner) and an agent get403 forbiddenwithreason: subject_kind_not_allowed(requirementAppKind, M1-28); an uninstalled app gets401(app_not_installed).listCostEstimates(cost.reconcile.read, R-AZ-28): an owner in a session (no step-up) and an app withcosts:reconcileingranted_scopesare allowed; an app without it gets403 scope_missing; an agent token, an owner's personal token and a non-owner person get403 forbiddenwithreason: owner_only(requirementAny(Owner, AppKind), whose failure names its first alternative, M1-28); an uninstalled app gets401(app_not_installed). The allowed read writes no audit entry (Auditno); every denial is audited (R-AZ-04).- Agents never hold
costs:reconcile(05 2.6); no agent path reaches any of the three actions.
-
Idempotency and concurrency.
Idempotency-Keyis handled by the stage of M1-20: a replay returns the stored answer withIdempotent-Replay: trueand inserts no rows and no job; another body under the same key gives422 idempotency_key_reuse; a repeat while the first runs gives409 request_in_progress; a401 step_up_requiredis not stored, so the same key works after the step-up. Two concurrent requests for the same run serialize on the locks of note 3c; afterwards every replaced entry has exactly one successor and the valid sum equals the amount of the request that committed last. Deadlocks are retried by the unit of work (database.tx_retry_max). -
API contract fixes in
docs/api/openapi.yaml(spec first; regenerate with the generator of M1-17):reconcileCostsgets the response'409'(Conflict, forrequest_in_progressof 06 7);submitCostReconciliationgets'422'(UnprocessableEntity, foridempotency_key_reuse);CostReconcile.providergetsminLength: 1;ReconcileEntry.amount_microsgetsminimum: 0;ReconcileEntry.currencygetspattern: '^[A-Z]{3}$'with the description "ISO 4217 code" asMoney.currency; the descriptions of both operations state the matching and the period rule of note 4 and that the entries are applied by the job. The read operationlistCostEstimatesof note 12 is already in the contract (note 1a). Keepsummarytexts unchanged so the generated tables of 06 do not change; if they do, runmake api-tablesand commit the result.make api-lintandmake api-checksstay green. -
The app
plugins/cost-reconciler(Ruling for docs/DECISIONS.md: name, language and shape). A Python project, because the rootMakefilediscovers Python projects underplugins/and runs format, lint, bandit, mypy, pytest with coverage and the license gate on them (23 3); flat layout with the packagecost_reconcilerand[tool.uv] package = falseliketools/pyproject.toml, started aspython -m cost_reconciler; standard library for HTTP (urllib.request,ssl), TOML (tomllib) and decimals. The working name of the product appears in none of its files (02 6, M1-46 test of places with the working name). It uses the public API only, without the SDK (M2-29 is not in the closure). a.pyproject.toml:requires-pythonastools/pyproject.toml; dev group withruff,mypy,pytest,pytest-covpinned like intools/; ruff rules includingDwith conventiongoogle(tests excluded); mypy strict over the package and the tests; pytest withfilterwarnings = ["error"]; coverage with branches andfail_underat the value ofquality.sonar.new_coverage_min_percent(23 11 names no key for Python components; the whole app is new code; spec gap reported). b. Command line:python -m cost_reconciler --config <file> [--once]. Without--onceit repeats passes untilSIGTERMorSIGINT, which end it after the current request. Exit codes:0success,1a pass failed (only with--once),2configuration error. Every request to the cost API, also across passes, starts at leastreconciler.min_request_intervalafter the previous one (monotonic clock, injectable for tests). The README says that a schedule running--oncemust not start more often than that interval. c. Configuration: a TOML file with the table[reconciler]and the sub-table[reconciler.workspace_map](workspace ID to a list of agent IDs). Keys: the three of 10 17 and, Ruling for docs/DECISIONS.md with rows added to 10 17 (place "Reconciliation app") andmake config-reference:reconciler.server_url(base address of the server API, no default, required),reconciler.token_ref(secret_refof the app token, no default, required),reconciler.admin_key_ref(secret_refof the provider admin key, no default, required),reconciler.cost_api_url(base address of the provider API from the documentation cited in 10 12.4, no default, required, so no host name is built into the code) andreconciler.http_timeout(time limit of one HTTP request, default the standard value ofsdk.http_timeout). Unknown keys, a missing required key, a duration outside the format of configuration.md 2, areconciler.lookbackthat is not a whole number of days, an emptyreconciler.workspace_mapand an agent ID listed under two workspaces are configuration errors that name the key, never a value. Both addresses must usehttps, excepthttpto a loopback address (tests, a local proxy). d. Secrets (secretref.py, 16 5.2):file:<absolute path>reads a file with mode0400or0600owned by the process user, else a configuration error (SEC-SEC-1);env:<NAME>reads a variable (allowed, not recommended); other schemes are a configuration error. The value is wrapped in a type whosereprandstrare a fixed redaction mark; only the HTTP clients unwrap it to build a header. Secrets are read again at the start of each pass, so a rotation needs no restart. A logging filter replaces the current secret values in every record with the redaction mark (defence in depth). e. Cost API client (costapi.py, 10 12.4 point 2):GET <reconciler.cost_api_url>/v1/organizations/cost_reportfor the window of complete UTC days from the start of today minusreconciler.lookbackup to the start of today, daily buckets, grouped byworkspace_idanddescription, authenticated with the admin key in the way the provider documentation cited in 10 12.4 (Q2) prescribes; follow the paging of the response to the end. Timeoutreconciler.http_timeout; no redirects (a3xxis an error, so the key never goes to another host, SEC-NET-4 applied to the app); a status other than2xxis an error that carries the status only, never the response body. A page without the expected bucket or line fields fails the whole pass before any submission (never submit from a partial report). Unknown fields are ignored. Write the fixtures after the provider documentation; the shape facts the spec states are daily buckets, grouping by workspace and description, costs in USD as a decimal string in cents. List the fields you relied on in the report under "What is not proven". f. Amounts (amounts.py): per bucket and workspace and currency, sum the decimal strings exactly (decimal.Decimal), then convert cents to micro units once (ten thousand micro units per cent) withROUND_HALF_EVENto an integer. An invalid decimal string fails the pass. g. Estimates (server.py): for each bucket, one call of the read operation of note 12 withfromandtoequal to the bucket bounds and the agent IDs of all mapped workspaces, following its cursor; group the items by the workspace of their agent. h. Distribution (distribute.py, pure, BR-10; Ruling for docs/DECISIONS.md): input the bucket amount T of one workspace and currency, and the estimates of the runs of its agents. Runs with another currency are left out and counted ascurrency_mismatch; runs with estimate0are left out (they stay estimates). No run left: the whole T is not assigned (no_runs); also when the remaining estimates sum to0(zero_estimates, the proportion is undefined). Otherwise share_i = floor(T times e_i divided by the sum of e), and the remainder T minus the sum of shares goes to the run with the largest estimate, ties to the smallest run ID. Workspaces without an entry in the map are not assigned (no_mapping). Integer arithmetic only. i. Submission (server.py): onePOST <reconciler.server_url>/api/v1/costs/reconciliationsper bucket with at least one entry:Authorization: Bearer <token>,Idempotency-Keya new UUIDv7 (uuid7.py, RFC 9562, because the standard library of the supported versions may lack it), bodyCostReconcilewithproviderthe provider name the runtimes report for this provider incost_entries.provider(theproviderexample ofCostReconcileinopenapi.yaml),period_startandperiod_endthe bucket bounds, the entries. No redirects.200: logreconciledand the unmatched run IDs.400: log the pointers and codes oferrors[], fail the pass.401,403: log status and problemcodeandreason, fail the pass.409,422,429,5xxand network errors: log and fail the pass; the next pass submits again and the server's no-op rule (note 4) prevents a second effect. No retries inside a pass (no SDK retry keys apply to the app). j. Logs (logs.py): JSON lines on stderr withts,level,eventand fixed fields (bucket start, workspace ID, currency, amounts in micro units, counts, run IDs, status, problem code); eventspass_started,pass_finished,pass_failed,not_assigned(with reasonno_mapping,no_runs,zero_estimates),currency_mismatch,submitted,unmatched_runs,config_error. Never a header, a token, a key, a request body or a response body of the cost API. This is how the app reports an amount without a matching run as "unassigned" (10 12.4 point 3). k. Manifest (manifest.json) in the format M2-24 validates (app_manifest.v1, schemaAppManifest; if the merged validator follows 11 3.1,scopes.required):schema_version1,slugcost-reconciler,name,description,publishera neutral value as in 11 3.3,versionequal toVERSION, exactly the scopecosts:reconcile, no events, no delivery, no commands, no components, no requested grants. The README describes the setup: the owner installs the manifest withinstallApp(step-up, reason) grantingcosts:reconcile, creates the app token withcreateToken(shown once), stores token and admin key as files with mode0600for the app's service user, and runs the app; DPoP is not used (Ruling for docs/DECISIONS.md: bearer only, becauseauth.dpop_required_fordoes not name apps by default and the token reaches only the two cost actionscost.reconcile.readandcost.reconcile.submit; withappin that list the app cannot work, which the README says). -
Read operation for the estimates (ids, right and class from the catalogue).
GET /api/v1/costs/estimates(listCostEstimates) with the query parametersfromandto(required timestamps,toexclusive, asgetCostStats),agent_id(required, repeatable) andcursorandlimitper 06 "Pagination";x-authz-actionthe read action;x-rate-limit-classits class. Items per run with valid or replaced entries of modeorganizationandoccurred_atin the range:run_id,agent_id,provider,currency,estimate_micros(sum of the run's estimate entries in the range whose successor, if any, is not an estimate: the estimate before any reconciliation) andreconciled(all valid entries in the range arereconciled). Sorted byrun_id. Runs with mixed currencies in the range are left out. The query uses the index(agent_id, occurred_at). Instance-level, so no visible-channel predicate and no per-item check such asrun.read(05 rule 1.2 and R-AZ-28, 07 7.4 rules 1 and 3); if the lint of M1-70 exists, add the query to its exception list with this reason. If the catalogue entry orlistCostEstimatesdiffers from this note, follow the contract and name the difference in the report. -
App, token and installation in tests. Tests install the app through
installAppwith the fileplugins/cost-reconciler/manifest.json(owner Alex with step-up), create its token throughcreateToken(M2-24) and use the token only through the app or the fake app client; scope removal goes throughsetAppScopes, removal of the app throughuninstallApp. Test worlds built through the API raiseratelimit.*in their configuration (R-QA-11). -
End-to-end and canary (
test/reconcile, Go, real server throughinternal/wirewith roleallso River works the job,pgtest, HTTP on127.0.0.1port 0). A fake cost API in Go serves the fixture pages ofplugins/cost-reconciler/tests/fixtures/and records every request. The test writes a TOML configuration and two secret files (mode0600) intot.TempDir(): the app token, and as admin key a random alphanumeric canary created for this run. It starts the real app as a subprocess,uv run --directory <repo>/plugins/cost-reconciler --locked python -m cost_reconciler --config <file> --once, waits for exit code0and then until nobilling.reconcilejob is pending. A wrapper around the server's handler records every request and response (method, path, query, headers, body); the server's logs go to a buffer. Afterwards the canary must appear in none of: any row of any base table of the domain schema and ofjobs.schema(one static statement overpg_classthat appliesquery_to_xml(format('SELECT * FROM %I.%I', ...))per table and searches the text withstrpos; no SQL is built in Go, rulebothy-go-sql-string-building; the canary is alphanumeric so XML escaping cannot hide it), the recorded server traffic, the server logs, the app's stdout and stderr. Positive control: the fake cost API received the canary in the authentication header of every request, so the scan cannot pass for the wrong reason.uvmissing is a failure, never a skip. -
Conditional extensions.
services/server/test/canary(M1-69) is in the closure: addreconcileCosts,submitCostReconciliationand the read operation to its scenario with a canary inprovider(SEC-T10: "The ticket that builds a new path ... extends the scenario"). If the visibility lint of M1-70 is merged, add the billing read queries of this ticket to its exception list with the reasons of notes 3 and 12. Ifmake typecheckcannot type-check the app from the repository root (it runsuv run --project <dir> --locked mypyin the root, and mypy reads its configuration from the working directory), change only that line to run inside the project directory as thetestrecipe does (cd $$p && $(UV) run --locked mypy) and name it in the report; if an earlier ticket already did, change nothing. -
Documentation. 10 12.4 gets the server side in English: the request stores the
reconciledentries after the matching rule and the split rule (notes 4 and 5), the period rule, the no-op rule, the job that applies them, and the app's reporting of unassigned amounts; 10 17 gets the new keys of note 11c. 02 4.3 and 12.3 as in notes 2 and 6.internal/billing/README.md: port withReconcileand the read method, eventCostsReconciled, jobbilling.reconcile(never periodic), lock order (advisory key namespacebilling.reconcile:<run_id>), the queries without visible-channel predicate and their reason.internal/runs/README.md: subscriptionbilling.CostsReconciled.internal/agents/README.md:ApplyCostTotals.plugins/README.md: one row (cost-reconciler/, app, cost reconciliation of 10 12.4, built by M2-28).test/reconcile/README.md: what the test proves and its prerequisites (uv, Docker). -
Rulings for docs/DECISIONS.md (one row each,
D-M2-28-<n>, with date, decision, reason and place): notes 2, 3h, 4, 5, 6e, 7 (noagent_session.changedfor reconciled totals, as M2-22 for bookings), 11 (name, language and shape), 11a (coverage key), 11c (new app keys), 11h (distribution edge cases), 11k (bearer only), and the precondition handling of note 1.
Tests (write them first)¶
Unit (Go):
internal/billing/domain:TestValidateReconcile: period start equal to and after end, a repeated run ID (pointer on the later one), several violations reported together.TestMatch: a table with no entry, no entry in the period, modepersonal, another provider, another currency, mixed currencies, an estimate in the period, entries partly outside the period (only the inside counts), reconciled with the same amount (no-op), reconciled with another amount (supersede).TestSplit: one entry, proportional shares summing to A, the remainder on the largest entry, a tie broken by the smallest ID, all amounts0(all on the smallest ID), A0, values near the int64 limit without overflow.internal/authz:TestCostReconcileRequirement: owner with a session and a fresh step-upallow; owner with an old step-upstep_up_required; app token withcosts:reconcile, agent token, owner's personal token and a non-owner person allforbiddenowner_only.TestCostReconcileSubmitRequirement: app with the scopeallow; app without itforbiddenscope_missing; owner session and agentforbiddensubject_kind_not_allowed.TestCostReconcileReadRequirement(R-AZ-28): owner with a session and no step-upallow; app with the scopeallow; app without itforbiddenscope_missing; agent token, owner's personal token and a non-owner personforbiddenowner_only.internal/wire:TestServerConfig_HasNoReconcilerKeys(note 3k); the txn golden file containsbilling.CostsReconciled -> runsandCheckTxnDirectionspasses;TestJobs_BillingReconcileNotPeriodic: the jobs registry has the kindbilling.reconcilein queuedefaultand no periodic entry for it.
Integration (PostgreSQL 18 via pgtest, the server connects as bothy_app (pgtest Config(pg.RoleApp)), never as bothy_owner, real modules through internal/wire, HTTP through the real chain, people from the identity test helpers, the app installed as in note 13, runs and estimates created through the booking path of M2-22 and the run helpers of M2-09; agents A1 and A2 in mode organization, A3 in mode personal with holder Alex):
TestReconcileCosts_OwnerWithStepUp: Alex with a fresh step-up reconciles two runs:200withreconciled 2, new rows exactly as note 3f, one jobbilling.reconcilewith both run IDs; before the jobruns.cost_microsis unchanged, after it the totals andcost_is_estimate = falsehold.TestReconcileCosts_StepUpRequired: without a fresh step-up401 step_up_required, nothing written; the same request with the sameIdempotency-Keyafter the step-up gives200.TestReconcileCosts_TokensRejected(R-AZ-03): the app token withcosts:reconcile, an agent token and Alex's personal token each get403 forbiddenwithreason: owner_only; no row, no job; one audit deny entry each (aggregation peraudit.deny_aggregate_windowrespected).TestSubmitCostReconciliation_App: the app with the scope gets200; the audit allow entry has exactly the details of note 3h.TestSubmitCostReconciliation_ScopeMissing: aftersetAppScopeswithoutcosts:reconcile:403 scope_missing, nothing written.TestSubmitCostReconciliation_PeopleAndAgentsRejected: Alex's session and an agent token get403 forbiddenwithreason: subject_kind_not_allowed; an uninstalled app gets401.TestReconcile_BothPathsUseSameJob(plan row): one request per path each inserts exactly one River job of kindbilling.reconcilein its own transaction (a rolled back request inserts none); the totals change only after the job ran; both jobs run the same handler.TestReconcile_BR10_EstimatesSuperseded(BR-10, server part): for a run with two models and a turn on another day, every valid entry in the period has exactly one successor withsource = 'reconciled',is_estimate = false,reconciled_atset and copied tokens; the valid sum in the period equals the submitted amount; the remainder sits on the largest entry; the entry outside the period is untouched;getThreadCostsshows the run with the new amount andis_estimate = falseafter the job.TestReconcile_OnlyAddsReconciledEntries(ADR 0055 "Confirmation"): a byte-equal snapshot of every row ofcost_entriesbefore the request is still present afterwards; all new rows arereconciledand point to an entry of a submitted run;bothy_appstill has noUPDATEorDELETEprivilege oncost_entries(has_table_privilege,has_any_column_privilege).TestReconcile_Unmatched: an unknown run ID, a run without entries in the period, A3's run (personal), a run of another provider and an entry in another currency are listed inunmatched_run_idsin request order;reconciledplus the unmatched count equals the entry count; nothing is written for them.TestReconcile_ResubmitSameAmountIsNoop: the same body under a new key writes no row and inserts no job and still counts the run as reconciled; a different amount supersedes the reconciled rows (chain estimate, reconciled, reconciled) and the job brings the totals to the new amount.TestReconcile_ConcurrentSubmissions: two parallel requests for the same run with different amounts both end with200; every replaced entry has at most one successor; the valid sum equals the amount of the request that committed last; after both jobs the run totals equal the valid sum.TestReconcile_JobUpdatesRunsAndSessions: after the jobruns.versionrose by one for changed runs only;agent_sessions.cumulative_cost_microsequals the valid sum of the session,cost_is_estimateisfalsewhen all its valid entries are reconciled,agent_sessions.versionrose by one for changed sessions only andcost_baseline_microsis unchanged.TestReconcile_JobIsIdempotent: running the same job again changes no row and no version.TestReconcile_Validation: the400 validation_failedcases of notes 3b and 10 with their pointers and codes; nothing written, no job.TestReconcile_IdempotencyKey: a replay returns the stored answer withIdempotent-Replay: trueand no new rows or job; another body under the same key gives422 idempotency_key_reuse.TestReconcile_UnknownMemberCanary: a body with an extra member holding a canary gives400 validation_failedwithunknown_member; the canary appears nowhere in the database (scan of note 14), the server logs or the stored idempotency answer.TestCostEstimates_Read(R-AZ-28): the app with the scope and an owner without a fresh step-up read the estimates of A1 and A2 for one day with paging;personalruns, mixed currencies and runs outside the range are absent; no item has a channel or thread member;estimate_microsstays the original estimate after a reconciliation andreconciledturnstrue; the allowed reads write no audit entry; an app without the scope gets403 scope_missing; an agent, Alex's personal token and Sam's session (not an owner) get403 forbiddenwithreason: owner_only, each denial audited.
End-to-end (test/reconcile):
TestReconcileApp_EndToEnd(BR-10 end to end, plan row "canary"): the fixture has two days; workspace W1 maps to A1 and A2, W2 to a third agent A4 in modeorganization, W3 has no entry in the map; on the first day A1 and A2 have runs with different estimates, one run with estimate0and one in another currency, A4 has one run, and A3 (personal) has a run that no workspace can reach; the second day has an amount for W1 but no run. Expected shares are written into the test as literal values computed by hand from the fixture. After the app's pass and the jobs: per day and workspace the reconciled valid entries of the mapped runs equal those values and sum to the fixture amount in micro units; the run with the largest estimate carries the remainder; the zero, the other-currency and thepersonalruns stay estimates; the app's stderr hasnot_assignedlines for W3 (no_mapping) and for W1 on the second day (no_runs); then the canary scan of note 14 finds nothing and the positive control holds.
Unit and integration (Python, plugins/cost-reconciler/tests, fakes on 127.0.0.1 port 0, no network):
test_distribute.py(BR-10): a table of cases and a seeded random loop: the shares sum to T; the remainder sits on the largest estimate (ties to the smallest run ID); zero estimates and other currencies are left out; no run givesno_runs; only zero estimates givezero_estimates; T0gives all shares0.test_amounts.py: exact sums of decimal cent strings, the conversion to micro units with half-to-even rounding, invalid strings rejected.test_config.py: every configuration error of note 11c names the key and never the value; defaults from 10 17;httpaccepted only for loopback.test_secretref.py:file:with modes0600and0400accepted,0644rejected (SEC-SEC-1);env:accepted; other schemes rejected;repr,strand formatting of a secret show only the redaction mark.test_costapi.py: request path, window of complete UTC days, grouping and daily buckets, paging to the end, the interval ofreconciler.min_request_intervalbetween requests with a fake clock, no redirect followed (the fake's redirect target receives nothing), an error status logged without the body, a malformed page failing the pass before any submission.test_server_client.py: bearer token, a fresh UUIDv7Idempotency-Keyper submission (version and variant bits, order), the handling of each status class of note 11i.test_pass.py: one submission per bucket with entries, none for buckets without entries, thenot_assigned,currency_mismatchandunmatched_runslog lines, exit codes of--once.test_canary.py: with a canary admin key the fake cost API receives it in the authentication header, and it appears in no request to the fake server (method, path, query, headers, body), no log line, no exception text (also on401and500from the cost API) and noreprof the configuration.test_manifest.py:manifest.jsonhas exactly the scopecosts:reconcile,versionequal toVERSION, no events, delivery, commands, components or grants.test_doc_gate.py:ruff checkwith the project's configuration on a temporary module with an undocumented public function reports aDfinding and exits non-zero (23 "Documentation in code": every gate has a test).
Test IDs of the plan row and the chapters:
- BR-10 (10 15): "Reconciliation distributes the daily amount over estimates: Sum equals actual amount, rounding remainder at the largest entry, estimates replaced":
test_distribute.py,TestReconcile_BR10_EstimatesSuperseded,TestReconcileApp_EndToEnd. - Canary "the server stores no vendor key":
TestReconcileApp_EndToEnd,test_canary.py,TestReconcile_UnknownMemberCanary,TestServerConfig_HasNoReconcilerKeys, andmake semgrepwithbothy-go-no-model-credentials. - "both paths apply the entries through the same job
billing.reconcile":TestReconcile_BothPathsUseSameJob. - "
cost.reconcilerequires step-up, an app token is rejected there (R-AZ-03)":TestReconcileCosts_StepUpRequired,TestReconcileCosts_TokensRejected,TestCostReconcileRequirement.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/billing/... ./internal/runs/... ./internal/agents/... ./internal/authz/... ./internal/wire/... ./test/reconcile/...cd plugins/cost-reconciler && uv run --locked pytestmake fmt-check,make lint,make typecheckmake codegen-check,make arch,make api-lint,make api-checks,make register-check,make config-doc-checkmake semgrep,make gitleaks,make licenses,make docs(it checks that the output ofmake config-referenceis committed)make check
Acceptance criteria¶
- Preconditions of Design note 1 checked and named in the report; the read operation is built exactly as 05 4.12 and 2.6 define
cost.reconcile.read(R-AZ-28) anddocs/api/openapi.yamldefineslistCostEstimates. -
reconcileCostsandsubmitCostReconciliation(and the read operation) are enabled and registered with their actions andrl.admin(the read operation with the class of its catalogue entry); AZ-P1 passes. - BR-10: the app's distribution sums to the actual amount, puts the remainder on the largest entry and reports unassigned amounts; the server replaces each estimate in the period by exactly one reconciled entry.
- The server stores no provider key: the canary admin key appears in no table, job, log, request or response of the server, the positive control holds, and the server's configuration has no
reconciler.key. - Both ways apply their entries through the same job
billing.reconcile; the job is never periodic and can run again without effect. -
reconcileCostsneeds an owner with a fresh step-up; app, agent and personal tokens get403 forbidden(owner_only) and are audited (R-AZ-03). -
submitCostReconciliationworks only for an app withcosts:reconcile; it only addsreconciledentries to existing runs and never changes or deletes an entry. - After the job,
runsandagent_sessionscarry the valid sums andcost_is_estimate = falsewhere everything is reconciled;agent_sessions.versionrose by one exactly for the sessions whose totals changed. - Audit entries carry period, counts and sums per currency only.
-
docs/api/openapi.yaml, 10 12.4 and 17, 02 4.3 and 12.3, the configuration reference, ADR 0055 "Confirmation", the READMEs anddocs/DECISIONS.mdare updated. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol in Go and a docstring on every public module, class and function in Python (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
billing,runs,agents,test/reconcile,plugins/cost-reconcilerand the row inplugins/README.mdupdated (port, event, job, lock order, the query exceptions, the app's setup, security model and limits). - Rulings of note 17 recorded in
docs/DECISIONS.mdsection 10 (one row each with date, decision, reason and place). services/server/CHANGELOG.mdentry under 'Unreleased';plugins/cost-reconciler/CHANGELOG.mdwith its first version.- 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), with attention to T-PL-01, T-TK-02 and RK-17: can any path put the admin key into the server or its logs, can an app token reachcost.reconcile, can a submission change or delete a non-reconciled entry or reconcile apersonalrun, can the app send the key to another host; 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.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.