| 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). |