Skip to content

M2-28 Cost reconciliation as its own app

No Date Ruling Reason
D-M2-28-1 2026-10-24 Preconditions (design note 1) hold on the base: cost.reconcile.read with app_scope: costs:reconcile in docs/spec/actions.yaml, listCostEstimates with CostEstimate and CostEstimatePage in the contract, agent_sessions and agents.Sessions of M2-12, and cost_entries with every column, check, key and index of 03. The authz entries of the three actions were merged already, so this ticket only adds their tests. The brief stops on a missing part; none was missing.
D-M2-28-2 2026-10-24 Shape across modules: the request writes only cost_entries and inserts the job; the job computes the totals and publishes the in-transaction event billing.CostsReconciled; runs subscribes, updates its rows and hands the session totals to agents.Sessions.ApplyCostTotals in the agents' own type. 02 4.3 names the event and the subscription. billing may use no other module (02 4.4); the same pattern as agents.AgentDisabled, no new module edge.
D-M2-28-3 2026-10-24 The audit entry of cost.reconcile and cost.reconcile.submit holds exactly period_start, period_end, entry_count, reconciled_count, unmatched_count and one total_micros_<currency> per currency of the matched entries; no provider, no run IDs. 10 14 names period, number of entries and sums per currency; the manual path records the same, which 10 14 does not list separately.
D-M2-28-4 2026-10-24 Matching: an entry for run R is matched when R has at least one valid entry with occurred_at in [period_start, period_end) and all of them have mode organization, the request's provider and the entry's currency; otherwise R is unmatched (no entry in the period, also an unknown or active run; personal; another provider; another or mixed currencies). A matched run whose valid entries in the period are all reconciled with the same sum is a no-op that counts as reconciled; another amount supersedes them. Entries outside the period stay. 10 12.4 states it. The app submits the same days on every pass within reconciler.lookback, so a repeat must not grow the chain; personal amounts are information, never invoice (03); no conversion (10 12.1).
D-M2-28-5 2026-10-24 Split within a run: share = floor(A times amount_j divided by the sum) over the valid entries in the period, the remainder to the largest amount, ties to the smallest ID; all amounts 0 put A on the smallest ID; every entry gets exactly one successor, also with 0. Exact with math/big. An amount above 2^53 - 1 is 400 validation_failed (too_large). BR-10 one level down: one request entry carries one amount per run, a run has one entry per model; no estimate may stay valid next to a reconciled amount; amounts stay inside the integer bound of 06.
D-M2-28-6 2026-10-24 The request stores the entries; the job billing.reconcile (args: the run IDs) applies them and reads its state again on every run; 02 12.3 says so. Requests take an exclusive transaction-scoped advisory lock per run (billing.reconcile:<run_id>), the job the shared form, both in ascending ID order. Job arguments carry only IDs (02 12.1, ValidateArgsType), and 03 has no other table for a submission; a repeated job has no second effect (02 12.4 rule 1); bothy_app has no UPDATE on cost_entries, so no row lock is possible.
D-M2-28-7 2026-10-24 The job writes no durable event: no run.updated and no agent_session.changed for reconciled totals; runs.version and agent_sessions.version rise by one only for changed rows, and cost_baseline_micros stays. A run or session whose valid entries mix currencies is left unchanged and logged by ID. 05 6 has no event for costs, as M2-22 for bookings; clients see the amounts on the next read (spec gap reported).
D-M2-28-8 2026-10-24 The app is a Python project plugins/cost-reconciler (flat layout, package cost_reconciler, [tool.uv] package = false, standard library only, run as python -m cost_reconciler); it uses the public REST API without the SDK and carries no working name of the product. The root Makefile checks Python projects under plugins/ (23 3); M2-29 (SDK) is not in the closure; 02 6 limits the places of the working name.
D-M2-28-9 2026-10-24 The coverage threshold of the app is fail_under = 80, the value of quality.coverage.python_min_percent, which tools/docs/tests/test_config_values.py holds equal for every Python project under plugins/. The brief named quality.sonar.new_coverage_min_percent because it found no key for Python components; the merged repository has one, and both are 80.
D-M2-28-10 2026-10-24 New keys of the app in 10 17 (place "Reconciliation app"): reconciler.server_url, reconciler.token_ref, reconciler.admin_key_ref, reconciler.cost_api_url (all required, no default) and reconciler.http_timeout (default the standard value of sdk.http_timeout). Both addresses use https, http only to a loopback address; reconciler.min_request_interval must be above 0. The app needs its server, token, key and provider address without a host name built into the code; a zero interval would turn the loop into a busy loop against the provider.
D-M2-28-11 2026-10-24 Distribution in the app: runs in another currency are left out and logged as currency_mismatch, runs with estimate 0 are left out; no run left is no_runs, estimates summing to 0 are zero_estimates, an unmapped workspace (also the default workspace null) is no_mapping; integer arithmetic only. The proportion is undefined without estimates, and amounts are never converted (10 12.1).
D-M2-28-12 2026-10-24 The app sends its token as Bearer without DPoP. With app in auth.dpop_required_for the app cannot work; the README says so. auth.dpop_required_for does not name apps by default, and the token reaches only cost.reconcile.read and cost.reconcile.submit.
D-M2-28-13 2026-10-24 Red team: the job takes the exclusive advisory lock billing.session:<session_id> of every session of its runs before it sums them, and BookRun of a run with a session takes the shared form first; a booking (AddCost, query SetSessionCost) marks the session an estimate again. Lock order: run locks, session locks, then the rows of runs and agent_sessions. The job writes an absolute session total, so a booking of another run of the session that commits between the job's sum and its write was lost, and a booking after a reconciliation kept cost_is_estimate = false (03: true until reconciliation).
D-M2-28-14 2026-10-24 Two SonarCloud findings of the app are marked NOSONAR with a reason: the HTTPS handler in transport.build_opener (S4830) and the read of the --config path in config.load_config (S8707). The handler uses ssl.create_default_context(), which checks the certificate and the host name (a test holds that); the --config path comes from the operator's command line, not from a model (as D-M2-34-14).