Problem
Every session Captain observes (rather than runs itself) reports a list-price reconstruction as its cost, permanently flagged as an estimate, with reasoning uncosted.
Five rollups prefer the provider's own figure when present — migrations/60_view_session_overview.sql:169, 62_view_session_turns.sql:77, 63_view_session_agents.sql:47, 67_view_session_costs.sql:40, and pkg/database/session_list_store.go:266 — all expressing the same rule:
CASE WHEN c.provider_cost_usd > 0 THEN c.provider_cost_usd
ELSE c.input_cost + c.output_cost + c.reasoning_cost + c.cache_read_cost + c.cache_write_cost END
and Estimated() is defined as ProviderCostUSD == 0 (pkg/claude/cost.go:70).
The only writer of provider_cost_usd is FinishChatModelCall (pkg/database/session_chat_store.go:225), on the live in-process chat path. The monitor ingest path never sets it: unifiedIngestInput populates InputCost, OutputCost, CacheReadCost and CacheWriteCost only (pkg/monitor/ingest.go:301-309), and upsertTurnCalls' DoUpdates list omits both provider_cost_usd and reasoning_cost (pkg/database/session_message_ingest.go:20-26).
This is not cosmetic. pkg/cli/result_costs.go:11 records replay-based pricing measuring roughly 9% below a real billed result.
This needs an attribution policy, not an assignment
There is no per-message cost in the Claude transcript. The session-level total_cost_usd appears in the result record (pkg/claude/reader.go:494); the cost_usd and total_cost fields at pkg/claude/stdin.go:25,29 belong to the streaming stdin protocol, not the JSONL. Per-turn cost is reconstructed from usage and price tables (pkg/session/cost.go:45 CostFromUsage).
So the work is deciding how one billed session total maps onto N turn rows, and doing it in a way that survives re-ingest. Options to weigh explicitly:
- Attribute the total to a single synthetic session-level row and leave per-turn rows as estimates, keeping the two clearly distinguishable.
- Distribute proportionally to per-turn reconstructed cost, accepting that individual turn figures become approximations of a real total.
- Store the billed total on the session rather than on model calls, and teach the rollups to prefer it at the session level only.
Whichever is chosen must be documented in code, because migrations/60_view_session_overview.sql:109-115 already warns that callers "must not present an estimate as billed cost."
The divergent rollup
migrations/69_view_prompt_run_overview.sql:87-93 sums the five list-price buckets and ignores provider_cost_usd entirely, contradicting migrations/75_model_call_provider_cost.sql:3 which asserts rollup views prefer it. The same session therefore reports different costs depending on the surface queried.
View 69 is additionally stale: it omits turn_id, runtime, approval state and the provider checkpoint columns, which is why pkg/database/prompt_run_overview.go:108 queries the base table and joins captain_session_overview instead of using the view.
Latent: currency
captain_model_calls.currency has no field in modelCallRecord (pkg/database/session_ingest_store.go:68-94) and no entry in either upsert, so the column default 'USD' (migrations/30_execution.pg.hcl:202-211) is the only reason WHERE upper(c.currency) = 'USD' matches anything. No current path demonstrates a real non-USD value being dropped, but a future one would silently vanish from every total.
Required change
- Decide, document and implement the cost-attribution policy.
- Write
reasoning_cost from ingest, and add both it and provider_cost_usd to upsertTurnCalls' DoUpdates.
- Fix
migrations/69_view_prompt_run_overview.sql to use the same rule as the other five surfaces, and bring its column set up to date.
- Write
currency explicitly at both write sites.
- Preserve every cost bucket independently.
migrations/76_model_call_cost_backfill.sql:3-14 records an earlier writer collapsing the whole cost into output_cost, leaving the per-bucket split unrecoverable.
Acceptance Criteria
Verification
cwd: .
timeout: 20m
codeBlocks: [test, lint]
ai: {}
verify:
scope: diff
threshold: 80
Focused tests
paths: [./pkg/session/..., ./pkg/database/..., ./pkg/monitor/..., ./pkg/cli/..., ./migrations/...]
framework: [go, ginkgo]
test-timeout: 12m
show-passed: true
Changed-code lint
changed: true
fix: false
timeout: 5m
Acceptance Criteria
Problem
Every session Captain observes (rather than runs itself) reports a list-price reconstruction as its cost, permanently flagged as an estimate, with reasoning uncosted.
Five rollups prefer the provider's own figure when present —
migrations/60_view_session_overview.sql:169,62_view_session_turns.sql:77,63_view_session_agents.sql:47,67_view_session_costs.sql:40, andpkg/database/session_list_store.go:266— all expressing the same rule:and
Estimated()is defined asProviderCostUSD == 0(pkg/claude/cost.go:70).The only writer of
provider_cost_usdisFinishChatModelCall(pkg/database/session_chat_store.go:225), on the live in-process chat path. The monitor ingest path never sets it:unifiedIngestInputpopulatesInputCost,OutputCost,CacheReadCostandCacheWriteCostonly (pkg/monitor/ingest.go:301-309), andupsertTurnCalls'DoUpdateslist omits bothprovider_cost_usdandreasoning_cost(pkg/database/session_message_ingest.go:20-26).This is not cosmetic.
pkg/cli/result_costs.go:11records replay-based pricing measuring roughly 9% below a real billed result.This needs an attribution policy, not an assignment
There is no per-message cost in the Claude transcript. The session-level
total_cost_usdappears in the result record (pkg/claude/reader.go:494); thecost_usdandtotal_costfields atpkg/claude/stdin.go:25,29belong to the streaming stdin protocol, not the JSONL. Per-turn cost is reconstructed from usage and price tables (pkg/session/cost.go:45CostFromUsage).So the work is deciding how one billed session total maps onto N turn rows, and doing it in a way that survives re-ingest. Options to weigh explicitly:
Whichever is chosen must be documented in code, because
migrations/60_view_session_overview.sql:109-115already warns that callers "must not present an estimate as billed cost."The divergent rollup
migrations/69_view_prompt_run_overview.sql:87-93sums the five list-price buckets and ignoresprovider_cost_usdentirely, contradictingmigrations/75_model_call_provider_cost.sql:3which asserts rollup views prefer it. The same session therefore reports different costs depending on the surface queried.View 69 is additionally stale: it omits
turn_id,runtime, approval state and the provider checkpoint columns, which is whypkg/database/prompt_run_overview.go:108queries the base table and joinscaptain_session_overviewinstead of using the view.Latent: currency
captain_model_calls.currencyhas no field inmodelCallRecord(pkg/database/session_ingest_store.go:68-94) and no entry in either upsert, so the column default'USD'(migrations/30_execution.pg.hcl:202-211) is the only reasonWHERE upper(c.currency) = 'USD'matches anything. No current path demonstrates a real non-USD value being dropped, but a future one would silently vanish from every total.Required change
reasoning_costfrom ingest, and add both it andprovider_cost_usdtoupsertTurnCalls'DoUpdates.migrations/69_view_prompt_run_overview.sqlto use the same rule as the other five surfaces, and bring its column set up to date.currencyexplicitly at both write sites.migrations/76_model_call_cost_backfill.sql:3-14records an earlier writer collapsing the whole cost intooutput_cost, leaving the per-bucket split unrecoverable.Acceptance Criteria
total_cost_usdonto persisted rows, with a test covering it.Estimated() == true.reasoning_costis populated by ingest and included in the upsert's update column list.turn_id,runtime, approval state and checkpoint columns.currencyis written explicitly by both the ingest and chat paths.Verification
cwd: .
timeout: 20m
codeBlocks: [test, lint]
ai: {}
verify:
scope: diff
threshold: 80
Focused tests
Changed-code lint
Acceptance Criteria
total_cost_usdonto persisted cost rows.Estimated() == true.reasoning_costis written by the ingest path and appears in theupsertTurnCallsupdate column list.provider_cost_usdis written by the ingest path and appears in the same update column list.captain_prompt_run_overview.migrations/69_view_prompt_run_overview.sqlexposesturn_id,runtime, approval state and provider checkpoint columns.currencyis written explicitly by both the ingest and chat write paths.