# Cost accounting (audit and plan)

> > **Status:** AUDIT of `origin/develop` at b902f5d3 (2026-10-13) plus a PROPOSED contract. Sections 1 and 2 describe code as it is; > every claim carries a file or function reference. Section 3 is the proposal, writte...

Canonical: https://chattic.us/wiki/cost-accounting/

> **Status:** AUDIT of `origin/develop` at b902f5d3 (2026-10-13) plus a PROPOSED contract. Sections 1 and 2 describe code as it is;
> every claim carries a file or function reference. Section 3 is the proposal, written in the spirit of
> [Live files protocol](/wiki/live-files-protocol) but shorter. Anything not verified by reading code or running it is
> labelled INFERRED or ASSUMPTION. No production code changed in this task. The Kanbus epic and tickets that carry the
> work are listed in section 6.
>
> **As built (T4, Kanbus `chatticus-49aa88`):** sections 1 and 2 describe the code BEFORE T4. Section 3.8 describes what T4 built and
> which findings it closed (F1, F3, F5(e)); where 3.2 and 3.3 differ from 3.8, 3.8 is the code. The spike decision (no `afterResponse`
> hook, derive from entries) is folded into 3.8; `COST_SPIKE_PI_DURABLE.md` stays as the record of the library contract.
>
> **As built (T5, Kanbus `chatticus-10e4ad`):** section 3.9 describes complete metering: compaction, errored and aborted responses, calls only the
> gateway saw, the voice understanding call, and the rollup's unpriced count. It closes F5(a) (for computer-owner turns), F5(b), F5(c), F5(d) and the
> voice part of F6, and it is the code where 3.3 differs.

## 0. The owner's vision, and the short answer

The vision: full transparency over the **actual direct costs** (AI API tokens priced from published vendor prices) per session,
per bot, per message or turn, per tool call or loop where possible, and per sub-agent, so people can compare ways of working
and see where money goes. The cost data lives **with the durable pi-durable session**, survives handoffs between workers,
computers and restarts, and stays at parity with durability features as they are built.

The owner's suspicion was "it probably is stored, but this needs checking". Checked result:

| Owner expectation | Today | Evidence |
| --- | --- | --- |
| Model token usage is recorded per turn | TRUE | After T4: `recordTurnCost` in `conversation/src/turn/finalize.ts`, which derives per-call records in the session and projects the ledger row from them |
| Dollars come from published vendor prices | PARTLY TRUE | A frozen price table (`vendor-prices.ts`) sourced from LiteLLM's catalog, a third-party list, not OpenAI's page. Cached input and the long-context tier are not modelled |
| Cost is recorded the same way on a Lambda worker and on a computer owner | TRUE after T1 and T4 | One writer (the owner, from the session); the gateway only logs. Scenario `cost_session_records_owners.feature` |
| Cost survives crash, retry and takeover without double counting or loss | TRUE after T4 for every call whose answer the session committed; a call the provider answered but the session never committed is still unrecorded (needs the T5 gateway observation) | Keyed idempotent records; `cost_session_records.feature` |
| Cost is stored together with the durable session | TRUE after T4 (a `chatticus.cost` record per call in the session). Before T4: FALSE for dollars, TRUE for raw tokens | The dollar ledger is a separate messaging-table row keyed by turn. The session itself does persist per-response tokens and pi-ai's own cost estimate (`message.usage`, and the `pi.usage` document), but nothing in Chatticus reads or exposes them |
| Cost per session / channel | FALSE | No row, route or screen sums by channel. The ledger row holds only tenant and turn |
| Cost per bot | Stored after T4 (turn index item has `bot_id`); no API or screen yet (T8, T9) | Before T4 the ledger row had no `bot_id` |
| Cost per message / turn | PARTLY TRUE | Per turn exists in storage (one row per turn). No API route and no screen shows it |
| Cost per tool call or loop | FALSE | One row per turn; no per-model-call or per-round data is kept in the ledger |
| Cost per sub-agent | FALSE | Chatticus has no sub-agent tool yet. When it does, `measureTurnUsage` reads only the root conversation, so a child's spend would be missed |
| Cost visible to users in the web app | FALSE | `web/` shows only the monthly AWS spend ceiling and the computer-paused notice. No model cost anywhere |
| Cost exposed over the HTTP API | FALSE | The only spend routes are `PATCH /orgs/{t}/monthly-aws-spend-ceiling` and ceiling fields on `/me` |
| Every model call is metered | TRUE after T5 for every call site that exists (section 3.9 lists them) | Turn calls (both owners), compaction summaries, errored and aborted responses, the voice understanding call, and calls only the gateway saw. Before T5: compaction, aborted or failed attempts and gateway-only calls were not (F5, F6). No title-generation, routine or other model call exists |
| Per-organization rollup | PARTLY TRUE | The daily rollup sums ledger dollars per organization per day (`conversation/src/budget/runner.ts`), but only into a budget alert and the computer-work meter; there is no readable report |
| Per-user rollup | FALSE | Ledger carries no user id |

**Plain version for copy writers:** Chatticus records model token spend per turn today and uses it for operator budget alerts.
It does not yet show anyone a cost, does not break cost down by bot, session, message, tool call or sub-agent, and the cost record
is not part of the durable session. Do not claim any of those until the tickets in section 6 land.

## 1. What exists today

### 1.1 Recording path

```
                 Lambda owner (turn-executor)                       Computer owner (container)
                 models: pi-ai OpenAI, real key                     models: pi-ai OpenAI -> model gateway (Front Door)
                                |                                                  |
   model loop runs inside the pi-durable session            each model HTTP call -> modelGatewayResponsesHandler
   (assistant entries carry message.usage)                   -> recordVendorSpend  (ADD tokens + dollars, per call)
                                |                                                  |
              conclude()/fail(): recordTurnSpend               same executeTurn core: conclude()/fail(): recordTurnSpend
              -> measureTurnUsage walks root entries           -> measureTurnUsage walks root entries again
              -> recordTurnSpendOnce (transaction, fenced)     -> recordTurnSpendOnce adds the whole session total (F1)
                                \                                      /
                                 messaging table row  {tenant}#vendor_ledger / turn#{turnId}
                                              |
                       daily rollup Lambda (budget/runner.ts) sums cost_usd per org per day -> budget_rollup row, alerts
```

### 1.2 What is stored, and where

| Store | Key shape | Content | Written by | TTL |
| --- | --- | --- | --- | --- |
| Messaging table, vendor ledger row | `pk = {tenant}#vendor_ledger`, `sk = turn#{turnId}` (`vendorLedgerKey` in `conversation/src/budget/budget-store.ts`) | `tenant_id`, `turn_id`, `vendor`, `model`, `input_tokens`, `output_tokens`, `billed_via` (`vendor` or `aws`), frozen `input_price_per_million_usd` / `output_price_per_million_usd`, `cost_usd` (8 decimal places, half-even), `recorded_at` | `recordVendorSpend`, `recordTurnSpendOnce` (`ledger/vendor-ledger.ts`) | none written (the table's TTL attribute is `expires_at`; ledger rows set none) |
| Voice ledger row | same `pk`, `sk = turn#voice:{uuid}` | one row per spoken line, same fields | `postVoiceMessage` in `conversation/src/domain/voice-messages.ts` | none |
| Turn record counters | `pk = {tenant}#turn#{turnId}`, `sk = meta`: `ledger_input_recorded`, `ledger_output_recorded` | how many tokens of the turn the ledger already holds, advanced in the same transaction as the ledger write under the attempt fence | `recordTurnSpendOnce` | none written for meta; events expire after 24 hours (`TURN_EVENT_TTL_SECONDS`) |
| Daily rollup | `budgetRollupKey(tenant, environment, date)` | `vendor_cost_usd`, `aws_cost_usd`, `combined_report_usd`, `ce_status`, alert events | `budget/runner.ts` | none |
| pi-durable session, entries | Conversations table partition `PI#{tenant}#{bot}#{channel}` (`IndexedStorage`, `storage/indexed-storage.ts`), record `R#{id}` of type `entry`; large bodies in the sessions S3 bucket | each assistant entry carries `message.usage`: `input`, `output`, `cacheRead`, `cacheWrite`, optional `reasoning`, `totalTokens`, and pi-ai's own `cost` (`input`, `output`, `cacheRead`, `cacheWrite`, `total`) from its model catalog | pi-durable | none |
| pi-durable session, `pi.usage` document | same partition, record of type `document`, scope = (conversation, `pi.usage`) | per conversation totals bucketed by `provider/model` and by tool name; per pi-durable's README "failed and aborted attempts count too" and compaction summarization counts | pi-durable (`UsageDoc`, `harness.usage()`) | none |

Key observation: the **session already stores usage per model response and a running total per conversation**, and pi-durable's
README documents that totals include failed, aborted and compaction attempts. Chatticus does not read `pi.usage` or
`harness.usage()` anywhere (grep of `conversation/src`: no match). It re-derives tokens by walking assistant entries from the turn's
prompt entry (`measureTurnUsage`), which skips compaction summarization (those attempts have no entries per pi-durable's `usage.d.ts`
comment) and any call that never produced an entry.

### 1.3 Pricing

`conversation/src/ledger/vendor-prices.ts`: `OPENAI_MODEL_PRICES` has `gpt-6-luna` (0.10 in / 0.50 out), `gpt-5-nano` (0.05 / 0.40), `gpt-5-mini`
(0.25 / 2.00) per million tokens, "fetched 2026-10-11" from LiteLLM. `VendorPriceBook.lookup` returns null for an unknown model; the row is
then written with tokens and no dollars. The first call of a turn **freezes** the price onto the row (`insertItem`); later additions use the frozen
rates (`accumulateUpdate`). For comparison, pi-ai's own catalog (`node_modules/@earendil-works/pi-ai/dist/providers/data/openai.json`) lists
`gpt-6-luna` with input 0.1, output 0.5, cacheRead 0.01, cacheWrite 0.125 and a tier above 272000 input tokens (input 0.2, output 0.75,
cacheRead 0.02, cacheWrite 0.25). Chatticus ignores the cache and tier fields.

### 1.4 The model gateway (`conversation/src/gateway/model-gateway.ts`)

* Admission: signed per-owner session token bound to tenant, turn, bot and owner; refuses a non-active turn or the wrong owner; model must be in
  `OPENAI_MODEL_PRICES`; `store` and `background` refused; function tools only; body at most 2 MiB; `max_output_tokens` clamped to
  `GATEWAY_MAXIMUM_OUTPUT_TOKENS` (32768).
* Recording: `spendRecorderFor` calls `recordVendorSpend` with `usage.input_tokens` and `usage.output_tokens` read from the final `response.completed` or
  `response.incomplete` event (`usageFromEvent`) or the JSON body (`usageFrom`). Cached and reasoning detail fields are not read.
* Interruption: if the client drops or the upstream breaks first, `meteredStream` records one estimate (`estimated_after_interruption`): input =
  request body bytes / 4, output = characters of text `delta` events seen so far / 4. Hidden reasoning tokens are therefore not estimated.
* Non-OK upstream: nothing recorded (`upstream_failed`), and the vendor's error text is withheld.
* Monthly model spend ceiling per organization (section 3.12): a call is refused once the organization's model spend for the calendar month has reached the ceiling. There is still no per-turn or per-session dollar ceiling.

### 1.5 Visibility

| Granularity | Recorded | API | Web |
| --- | --- | --- | --- |
| Model call | Session entries hold tokens and pi-ai cost (not read). Gateway sees each call but sums it away | none | none |
| Round or loop within a turn | no | none | none |
| Tool call | no (tool events exist, with no cost) | none | none |
| Turn | yes, one ledger row, tokens plus dollars | none | none |
| Message | indirect: a turn has `promptMessageSeq` and `messageSeq`, ledger row has `turn_id`; nothing joins them | none | none |
| Session or channel | no | none | none |
| Bot | no (`bot_id` is on the turn record only) | none | none |
| Member | no (`promptAuthorId` is on the turn record only) | none | none |
| Sub-agent | not applicable yet; would be missed (F4) | none | none |
| Voice line | yes, `voice:{uuid}` row, tenant only | none | none |
| Organization per day | yes, `vendor_cost_usd` in the daily rollup | none (feeds `computer_work_paused` on `/me`) | paused notice only |
| Organization AWS spend | Customer account: Cost Explorer through its role. Platform-hosted: recorded owner runtime priced at the Fargate ARM64 rate (docs/BUDGETS.md) | ceiling field on `/me` | `SpendCeilingForm`, `ComputerPausedNotice` |

Routes: `grep` of `conversation/src/http` finds no route returning tokens or model dollars. `web/` greps for `vendor`, `ledger`, `cost_usd`, `input_tokens` find nothing in
product code; "spend" appears only for the AWS ceiling (`web/lib/spend-ceiling.ts`, `web/components/SpendCeilingForm.tsx`).

### 1.6 Other model calls

Searched `conversation/src` for `completeSimple`, `streamSimple`, `.stream(`, `createModels()`, `api.openai.com`. The only live model callers are:
the bot turn (pi-durable harness through `createOpenAiModels` or `createGatewayModels`), the voice understanding call
(`conversation/src/voice/understanding.ts`, `gpt-5-nano`, minimal reasoning), and pi-durable's own compaction. There is no title generator, no routine model call, and no
sub-agent tool (the `task` tool in `pi/task-tool.ts` is a household to-do tool, not a delegate). `migration/copy.ts` calls `createModels()` only to open storage.

## 2. Gaps and risks, ranked

Severity: **A** wrong numbers now or silent loss, **B** blocks the vision, **C** quality.

### A: correctness

**F1 (A, by code reading, not reproduced): a turn run by the computer owner is counted twice.**
`takeOverTurn` (`turn/computer-owner.ts`) calls the same `executeTurn`, whose `conclude()` and `fail()` call `recordTurnSpend`. On the container the
models also go through the gateway, which already `ADD`ed each call to the same ledger row (`recordVendorSpend`). `recordTurnSpendOnce` computes its delta
from `ledger_input_recorded`, a counter only that function advances, so it starts at 0 and adds the **whole** session total again, at the row's frozen
price. Net: tokens and dollars of every container-run call are doubled. `docs/PI_SESSION_HANDOFF.md` records "spend recorded through the gateway" as a live smoke and no
scenario covers both writers on one turn (`features/model_gateway.feature`, `features/vendor_ledger.feature` each cover one writer). Also, the container's ledger dependency uses an
empty `new VendorPriceBook()` (`computer/host/src/owner-deps.ts:76`), so if finalize ever inserted first it would write tokens with null dollars. A turn that starts on Lambda, parks and finishes on the
computer double counts only its container part. Ticket T1 writes the failing scenario first.

**F2 (A): unpriced model means silent zero.** `costUsdFromTokens` returns null without a price, and the rollup skips null (`budget/runner.ts`, `row.costUsd === null`). The gateway refuses unpriced
models, but the Lambda owner does not: switching `DEFAULT_TURN_MODEL` (`turn/openai-models.ts`) to a model absent from `OPENAI_MODEL_PRICES` records tokens and shows $0 in every meter. There is no alarm.

**F3 (A): one model and one frozen price per turn.** `measureTurnUsage` takes `provider` and `model` from the **first** assistant message (its doc comment says newest), and the ledger row holds one `model`. A turn that switches
model mid-way is priced entirely at the first model's rates. Not live today (single model), but the row shape cannot express it.

**F4 (A, future): sub-agents and forks would be uncounted.** pi-durable supports task-owned child conversations (README "Abort and Subagents") and forks, each with its own `pi.usage`.
`measureTurnUsage` calls `root.entries(...)` only. The first sub-agent extension would spend tokens that no ledger row contains. This is the central parity risk the owner anticipates.

**F5 (A): calls with no entry are lost.** (a) Lambda owner: an attempt that dies, times out or yields while a model call is in flight leaves no assistant entry; the call is billed by the vendor and recorded nowhere
(the gateway path at least records an estimate). (b) Compaction summarization calls have no entries (pi-durable `usage.d.ts`). (c) Aborted or errored responses may carry usage that the entry walk does not see
(INFERRED; pi-durable README says they count in `pi.usage`, whose totals Chatticus does not read). (d) Gateway: a ledger write failure after the vendor answered fails the response and loses the spend (`recordOnce` awaits before closing the stream).
(e) Spend is recorded only at `conclude()` and `fail()`. A turn that is `yielded`, `parked` or `lost` writes nothing at that moment; its tokens are picked up later by the next attempt's cumulative walk, so they are not lost unless the turn never concludes.

**F6 (B): token fidelity.** The ledger has two counters. Cached input is summed into input at the full input rate (finalize adds `input + cacheRead + cacheWrite`; the gateway reads `input_tokens`), a documented overestimate: for `gpt-6-luna` cached input is
0.01 versus 0.10 per million. The long-context tier (input above 272000 tokens, per request) cannot be applied after aggregation. Cache writes (0.125) are not priced. Reasoning tokens are inside `output`
(correctly billed as output) but cannot be shown separately. Voice understanding drops `cacheRead` (`message.usage.input` only).

**F7 (C): rollup and attribution mechanics.** `recorded_at` is the first call's time, so a long turn is attributed wholly to its first day. `listVendorLedgerRowsForTenant` queries the whole tenant partition on every rollup run with no month key, and ledger rows never expire (fine at household scale, an O(all history) read per run).
Voice failure to record is swallowed with a `console.warn` (`domain/voice-messages.ts`).

### B: vision gaps

* **G1** No dimensions: the ledger row has no `bot_id`, `channel_id`, `user_id` or `conversation_id`. Per-bot, per-channel and per-member views would require joining to turn records.
* **G2** No per-call or per-round granularity, so per tool call, per loop and "where money goes inside a turn" are impossible.
* **G3** The cost record is not in the session. A session moved or restored elsewhere (copy of the `PI#...` partition and its S3 objects) carries tokens and pi-ai estimates but not Chatticus's priced, attributed record. There is no environment-to-environment mover in the repo today (the migration CLI moves legacy transcripts into Pi), so portability is by design intent only.
* **G4** No API, no web view, no export, no comparison.
* **G5** No price provenance: no price book version id or source on a row, no verification against OpenAI's page, no scheduled check; `docs/BUDGETS.md` itself says "re-check it when a price matters".
* **G6** No parity guard: nothing fails when a new conversation kind, tool or model call is added without cost accounting.
* **G7** No per-turn or per-session dollar ceiling in the gateway (a budget feature, listed so it is not mistaken for cost transparency). The per-organization monthly model ceiling is built (section 3.12).

### Existing Kanbus tickets (`KANBUS_NO_DAEMON=1 kbs list`, filtered by cost, spend, ledger, price, usage)

Open and relevant: `chatticus-12ba7f` (cached input and long-context tier not modelled; this plan's T3 supersedes it), `chatticus-9563cb` (a member sees the organization **AWS** spend; different meter), `chatticus-9860a5` (published AWS cost of one computer), `chatticus-26f253` and `chatticus-9ac621` (rollup not deployed, AWS meter false zero; these affect the combined meter, not per-turn accounting), `chatticus-d706d7` (cost allocation tags).
Closed and foundational: `chatticus-89adc0` (ledger of vendor spend per turn), `chatticus-08851f` (TS-21 spend recording from Pi usage), `chatticus-052202` (spend ceiling), `chatticus-2e3e94` (budget epic, still open). `kbs search` is not a subcommand in the installed `kbs`; `kbs list` with a filter was used instead. No existing ticket covers per-bot, per-session, API or web cost visibility, or the computer-owner double count.

## 3. Target design (PROPOSED contract)

### 3.1 Principles

1. **The session is the source of truth for tokens.** Every priced figure must be derivable from records that live in, and move with, the pi-durable session.
2. **Idempotent by key, not by counters.** A cost record is identified by (conversation id, entry id or call id). Writing it twice is a no-op. This removes F1, the attempt-fenced delta counters and the dual-writer problem structurally.
3. **One writer for dollars.** The gateway stops adding to the ledger. It remains the enforcement point (admission, clamps, later ceilings) and logs an *observation* used only for reconciliation (3.6).
4. **Price at record time from a versioned price book, and keep the version on the record** so history is reproducible and a corrected price can be re-derived from raw counters.
5. **Counters, not just dollars.** Store `input` (uncached), `cacheRead`, `cacheWrite`, `output`, `reasoning`, plus context size so tier selection can be recomputed.
6. **Every conversation of a session is in scope.** Root, forks and task-owned children are enumerated through the storage indexes (`CC#`, `CT#` on `IndexedStorage`), never only the root.
7. **No dual readers.** The old per-turn ledger shape is migrated, not kept beside the new one (AGENTS.md).

### 3.2 Data model

**Call cost record** (one per model call; the unit that makes everything else a sum):

```
ModelCallCost {
  version: 1
  conversationId       // root, fork or task-owned child
  entryId | callKey    // the assistant entry; for calls with no entry (compaction, aborted) a deterministic key
  kind: "turn_round" | "compaction" | "aborted" | "voice_understanding"
  turnId, round        // round = ordinal of the model call within the turn
  provider, model
  tokens { input, cacheRead, cacheWrite, output, reasoning, contextTokens }
  toolCallIds[]        // tool calls this response requested (per tool call or loop attribution)
  priceBookId          // e.g. "openai@2026-10-11"; names the source and date
  tier                 // "standard" | "long_context"
  costUsd | null       // null only when the model has no price; carried as `unpriced: true`, never silently zero
  recordedAt
}
```

Where it rides: **a pi-durable document per conversation** (a Chatticus `defineDoc`, the same mechanism as `ChannelLogDoc` in `conversation/src/pi/channel-log.ts`, name `chatticus.cost`), keyed by entry id, so it is committed with the session, is snapshotted and
copied with it, survives owner handoff and restart, and a restored session keeps its history. ASSUMPTION to verify in ticket S0: pi-durable's `GenerationHooks.afterResponse(message, api, context)` runs in or adjacent to the commit that records the response
and can write a document there; if it cannot, the fallback is to write the same records at the existing finalize points, idempotently keyed, which is still correct because it is keyed by entry id.

**Turn index** (messaging table, replaces the vendor ledger row; one per turn, written by the owner at turn end and on yield, park and failure, idempotent):

```
pk = {tenant}#cost#{yyyy-mm}      sk = turn#{turnId}
tenant_id, turn_id, bot_id, channel_id, prompt_author_id, prompt_message_seq, message_seq,
owner_kind ("lambda" | "computer"), root and per-child subtotals, per-model token counters,
price_book_id, cost_usd, unpriced_calls, status, updated_at, expires_at absent
```

The month in the partition key bounds rollup reads (F7) and lets a month be exported by one query. Non-turn rows (voice) use `sk = voice#{uuid}` with `bot_id` null and the posting member recorded. Attribution of a **message** is by join on `prompt_message_seq` (cost the message triggered) and `message_seq` (cost of producing the reply).
A **session or channel** total is a query over `channel_id` (GSI or a sibling item `pk = {tenant}#cost#channel#{channelId}` updated in the same transaction; decide in S0). A **bot** total is the same by `bot_id`.

**Sub-agents:** a child conversation owned by a task writes its own `chatticus.cost` document with `parentConversationId`, `parentTaskId` and the spawning tool call id. The turn index stores root and child subtotals so "what did spawning this sub-agent cost" is the child subtotal, and the parent's own cost excludes it. Loops (background anchors, repeated rounds) are visible as rounds under the same task.

**Price book:** `conversation/src/ledger/vendor-prices.ts` becomes versioned (`priceBookId`), carries input, output, cacheRead, cacheWrite and tiers for every allowed model, records its source (OpenAI's pricing page, with the retrieval date) and has a test that fails if the gateway-allowed model list and the book disagree. Chatticus computes dollars itself and keeps pi-ai's `usage.cost.total` only as a cross-check.

### 3.3 Write rules

* The **owner** (Lambda or computer, same code path) derives call records from the session at: each `afterResponse` (preferred), and at conclude, fail, yield and park as a safety net. Keyed writes mean repeats cost nothing.
* Compaction, aborted and errored responses come from `pi.usage`/hook data, not from entries.
* The **gateway** writes an `observed` item per call (`pk = {tenant}#cost#observed`, `sk = turn#{turnId}#{n}`, TTL 35 days) with raw usage and its estimate flag. A reconciliation job compares observed totals to session-derived totals per turn and flags a gap above a tolerance. The gateway no longer touches the ledger.
* A model absent from the price book is refused at the gateway (already true) **and** at the Lambda owner (new): the turn fails closed with a clear reason rather than metering zero.
* Voice understanding records the call (with `cacheRead`) into the new index with member and channel, and a failure to record is logged at error level and counted, not swallowed.

### 3.4 API (all under the member principal; proposed: an organization owner sees everyone, a member sees own prompts and channels they belong to: Q4. As built in section 3.10, every enabled member sees the organization's cost until Q4 is answered)

* `GET /orgs/{t}/channels/{c}/cost` : session total, per-turn list, per-bot split, per-model split.
* `GET /orgs/{t}/turns/{turnId}/cost` : the call tree: rounds, tool calls, child conversations, tokens and dollars, `unpriced` markers, price book id.
* `GET /orgs/{t}/costs?group_by=bot|channel|member|model|day&from=&to=` : rollup from the monthly partitions.
* `GET /orgs/{t}/costs/export?format=csv|ndjson&from=&to=&grain=call|turn` : export of call or turn records, headers include price book id.
* `GET /orgs/{t}/costs/compare?channel=a&channel=b` (or `turn=`): side-by-side of totals and normalized metrics (cost per turn, per tool call, per sub-agent, cached ratio, rounds per turn).

### 3.5 Web

* Channel header chip: session total and a popover by bot and model.
* Per-message cost marker on each bot reply and each prompt (cost it triggered), expanding to the turn's rounds and tool calls and sub-agents.
* "Costs" page for the organization: group-by selector, date range, per-bot and per-channel bars, a table that exports.
* Compare view: pick two or more channels or turns; show totals and normalized metrics; label each with the workflow facts that differ (sub-agent count, rounds, models).
* Honest states: "unpriced", "estimated after interruption", "price book changed" are shown, never hidden. Copy says "model token cost at published list prices", never "your bill".

### 3.6 Reconciliation, parity and portability

* **Parity guard (G6):** a vitest structural guard enumerates every call site that can reach a model (`completeSimple`, `streamSimple`, harness generation, compaction, gateway) against an allowlist of metered ones; adding an unmetered caller fails the test. A Gherkin scenario drives a sub-agent and a compaction and asserts the turn total equals the sum of session records. `AGENTS.md` gains a one-line rule: a feature that creates conversations, calls a model, or moves a session must carry cost accounting in the same PR.
* **Handoff and crash:** scenarios for takeover mid-round, crash after a call but before finalize, retry of the same turn, yield/park/resume, and Lambda to computer to Lambda; each asserts the session-derived total equals the sum of calls exactly once.
* **Portability:** the cost document is part of the session partition; a session copy test asserts the copied partition reproduces the same totals with no other table.
* **Migration:** a one-shot job converts existing `{tenant}#vendor_ledger` rows into turn index items (tokens and dollars preserved, `priceBookId = "legacy"`, dimensions filled from the turn record where it still exists, otherwise null), then deletes the old rows; the rollup and the budget code read the new shape only.

### 3.7 Proposed Gherkin scenario list

New feature files, added to `features/cucumber-features.txt`: `cost_call_records.feature`, `cost_session_portability.feature`, `cost_attribution.feature`, `cost_api.feature`, `cost_gateway_reconciliation.feature`, `web_costs.feature`.

1. A computer-owner turn is counted exactly once (regression for F1).
2. A Lambda turn, a parked turn resumed on the computer, and a computer turn handed back to Lambda sum each call exactly once.
3. A crash after a model call and before finalize does not lose or double the call.
4. A retried turn re-derives the same call records.
5. A yielded turn leaves its calls recorded before the next attempt starts.
6. A call with cached input is priced at the cache rate; an input above the long-context threshold is priced at the tier rate.
7. Reasoning tokens are shown within output and priced as output.
8. A model missing from the price book fails the turn closed on Lambda and is refused at the gateway.
9. A turn that switches model prices each call at its own model.
10. A compaction summary call appears as a `compaction` record in the turn's cost.
11. An aborted or errored response with usage is recorded and marked.
12. A gateway interruption records an observation flagged `estimated` and does not change the ledger total.
13. Reconciliation flags a turn whose observed and session-derived totals differ beyond tolerance.
14. A sub-agent's cost is attributed to the child and rolled into the turn, not the parent's own cost.
15. A fork's cost is counted once and not in its parent.
16. Cost per tool call: the response that requested a tool is linked to that tool call.
17. Cost per message: a prompt message shows the turn cost it triggered; a bot reply shows the cost of producing it.
18. Cost per bot and per channel sum to the organization total.
19. Voice understanding is recorded with member and channel and a recording failure is reported.
20. A copied session partition reproduces the same totals.
21. Cost API: a member sees own and channel costs; a non-member is refused; an owner sees all (per Q4).
22. Export returns call records with the price book id.
23. Compare returns normalized metrics for two channels.
24. The web channel header shows the session total; the message marker expands to rounds; the Costs page groups by bot; unpriced calls are labelled.
25. Migration of legacy ledger rows preserves totals.
26. Adding a model caller without metering fails the parity guard (vitest, structural).

### 3.8 As built (T4)

**One writer.** `deriveCostRecordsFromEntries` (`conversation/src/ledger/call-cost.ts`) is the only code that turns a model call into a
cost. It scans the assistant entries of one conversation from the turn's prompt entry on, skips entries whose `conversationId` is not
that conversation (a fork's scan returns its parent's entries), and writes one record per entry in a single commit, skipping keys that are
already filled. A record is a pure function of the entry and the price book, so any owner derives the same record.

**Where it lives.** The `chatticus.cost` document family of the Pi session (`defineDocFamily`, scope conversation, history latest,
`fork: "initial"`, so a fork starts with no records), key `entry-<entryId>`. It moves with the session partition: a fresh reader handle that
replays nothing and a new owner under a new fence both read the same records. Each record holds the raw counters (uncached input, cache
read, cache write, output with reasoning as a subset, context size), provider, model, response id, turn id, round (the ordinal of the call in
the turn), price book id, tier, labels, the applied rates, `costUsd` (decimal text, null with `unpriced` for a model the book lacks), pi-ai's
own estimate as a cross-check, and the time it was derived. Kind is `turn_round`; T5 adds compaction and other kinds to the same family.

**When it runs** (`recordTurnCost` in `turn/finalize.ts`, called from `turn/executor.ts`): at conclude (after the reply is committed), at fail,
at yield, at park (before the turn is parked on the computer), and when an owner resumes a turn that an earlier attempt began (before its
first model call). A takeover by the computer owner is the same executor, so the same points apply. The prompt entry is found from the
session itself (the submission with request id `turn:<turnId>`), not from in-memory state, so a new owner needs nothing from its predecessor.

**The projection.** After deriving, the owner reads the turn's records back and sums them (`summarizeTurnCost`, `ledger/turn-cost.ts`). That
one summary is written, as absolute totals, to two items in one transaction: the vendor ledger row `{tenant}#vendor_ledger / turn#{turnId}`
(kept in its existing shape because the daily rollup and budget alerts still sum it until T7) and the turn index item
`{tenant}#cost#{yyyy-mm} / turn#{turnId}` with `bot_id`, `channel_id`, `prompt_author_id`, `prompt_message_seq`, `message_seq`, `owner_kind`
(the kind of owner that last wrote it), `status` (`running`, `yielded`, `parked`, `completed`, `failed`), per-model subtotals, `price_book_id`,
`cost_usd`, `unpriced_calls` and `record_count`. Both items carry the number of records they were written from and refuse a write with fewer, so a slow
older writer cannot shrink a row. There is no second counting path: nothing adds a delta anywhere.

**Removed.** `recordTurnSpendOnce`, `TurnSpendProgress` and the frozen-first-write delta arithmetic in `ledger/vendor-ledger.ts`; the turn record
fields `ledger_input_recorded` / `ledger_output_recorded` (`domain/turns.ts`, `store/turn-store.ts`); `measureTurnUsage` and `recordTurnSpend` in
`turn/finalize.ts`. The gateway already stopped writing the ledger in T1 and only logs `usage_observed`. `recordVendorSpend` remains for the voice
understanding call only (T5 moves it). The container's empty price book was replaced by the default book in T3.

**Findings closed.** F1 (double count) by T1 and T4. F3 (one model per turn): every record carries its own model and is priced by it. F5(e)
(nothing recorded at yield, park or while the turn is unresolved): records are written at those points. Closed by T5 (section 3.9): F5(a) for computer-owner turns (a call with no committed
entry, through the gateway observation), F5(b) compaction, F5(c) aborted and errored calls, F6 voice. Still open: F4 children and forks beyond the `fork: "initial"` and conversation-filter
guards (T6).

**Proof.** `features/cost_session_records.feature`, `cost_session_records_owners.feature`, `cost_session_records_handoff.feature`; mutation
checks on the entry key, the conversation filter and the fork mode each fail a named scenario (see the pull request).

### 3.9 As built (T5)

**Every model call site, and how it is metered.** The search was `completeSimple`, `streamSimple`, `.stream(`, `createModels`, `createOpenAiModels`,
`createGatewayModels`, `api.openai.com`, `/responses`, `Harness.open` and `OPENAI_API_KEY` over `conversation/src`, `computer/host/src`, `web/` and
`infra/lib`. These are all the places that can reach a model:

| Call site | Where | Metered by |
| --- | --- | --- |
| Bot turn rounds on a Lambda owner | `turn/executor.ts` through `createOpenAiModels` (`turn/openai-models.ts`) | `turn_round` records derived from assistant entries (T4) |
| Bot turn rounds on the computer owner | `computer/host/src/owner-models.ts` `createGatewayModels` into the gateway | the same records, plus a gateway observation per call |
| Errored and aborted responses | the same two paths; Pi appends them as assistant entries | the same `turn_round` records, with the tokens the provider had counted (zero when it had counted none) |
| Compaction summaries | Pi's own `CompactionTask`, inside the turn, on either owner | `compaction` records (below) |
| Voice understanding | `voice/understanding.ts` `completeSimple`, called by `domain/voice-messages.ts` | a `voice_understanding` record (below) |
| The model gateway | `gateway/model-gateway.ts`, the route containers reach | an observation per call, the only cover for a call the session never committed (below) |
| `migration/copy.ts` | opens a session through `createModels()` | not a call site: it never calls a model |

There is no title generator, routine model call or sub-agent tool. `pi.usage` also has a `tools` bucket for tool results; no Chatticus tool calls a
model, so it is empty and nothing reads it. A tool that ever calls a model must carry its own cost record in the same change (T6 adds the structural
guard that enforces this).

**Compaction.** Pi's compaction summary is a model call with no assistant entry, but the session's usage ledger (`pi.usage`) counts it. A
conversation's compaction spend is that ledger minus what its assistant entries account for, per model. `deriveCompactionCostRecords`
(`ledger/call-cost.ts`) computes the remainder, subtracts what the conversation's compaction records already hold, and writes the difference as a
record of kind `compaction` (round 0) in the same `chatticus.cost` family, keyed by the cumulative spend it brings the conversation up to
(`compaction-<provider>-<model>-<input>-<cacheRead>-<cacheWrite>-<output>`), so deriving again writes nothing and a later compaction adds only its own
spend. It reads the ledger before the entries, so a response committed between the two reads shrinks the remainder and is then counted as its own entry,
never as compaction. It runs wherever the entry derivation runs (`deriveCostRecords`, called by `recordTurnCost`), attributes the spend to the turn that
derives it, and costs one extra scan of the conversation's entries per derivation (household scale). The compaction policy knob `keepRecentTokens` is
now an executor tuning field (default Pi's 20000) so a scenario can make a short conversation compactable.

**Errored and aborted responses.** Pi appends both as assistant entries with the usage the provider had counted (`appendAssistant`, and
`convertPartial` for a cut-off stream), so they were already records; T5 proves it with scenarios for an error that reported usage and a cut-off stream
that reported partial usage. A response that failed or was cut off before the provider counted anything is a record with zero tokens, and the vendor may
still have billed it. The session cannot know that amount; the gateway observation can (below).

**Gateway observations and reconciliation.** The gateway keeps one item per answered call: `pk = {tenant}#cost#observed`, `sk = turn#{turnId}#call#{callId}`,
TTL 35 days (`expires_at`). `callId` is the vendor response id when the gateway saw one, otherwise its own request id, so one call is one item. The item
holds the model, the vendor's input tokens (cached included), cached tokens, output and reasoning tokens, `estimated` (true for an interrupted stream or an
answer with no final usage; the figures are then the gateway's estimate) and `reconciliation`. A final figure replaces an earlier estimate of the same call
and an estimate never replaces a final figure. The gateway never writes the ledger; failing to keep an observation is logged (`observation_not_recorded`) and
never fails the response.

`reconcileTenantObservations` (`ledger/reconcile.ts`) judges each observation against the ledger row of its turn, which carries the response ids of the
session records that have tokens and the tokens of the turn's compaction records: **matched** (its response id is on the row: the same call, not a cost),
**compaction** (it fits inside the turn's compaction tokens, because a summarization has no response id in the session), **pending** (nothing accounts for
it yet and it is younger than two hours, because a long turn writes its records only when it yields, parks, concludes or fails), or **orphan** (a call the
vendor answered and the session never committed). An orphan becomes a ledger row `turn#gateway:{callId}` and a cost index item `gateway#{callId}` in the
month of the call, priced from the book, with `source: gateway`, `estimated`, and the label `gateway_estimate` when estimated. The verdict is recomputed on
every run, so the job is idempotent, and an orphan whose session record appears later is withdrawn (its rows deleted). A turn whose orphan tokens exceed 5
percent of its session tokens is flagged in the outcome. The zero-token record of an aborted response lists no response id, so the gateway estimate of
that same call is not matched and is counted. Known limit: a lost call whose tokens happen to fit inside a compaction budget of the same turn is absorbed
as compaction; that needs a turn with a compaction and a lost call together.

**Who reconciles.** The daily rollup (`budget/runner.ts`) calls the reconciler for each organization before it sums the day, so the day's dollars include
orphans and exclude matched calls. The sum also skips a gateway row whose call id is on any session row's response ids, so it does not depend on the
reconciler having deleted that row yet. Reads of the cost index added later (T8) must apply the same rule.

**Voice understanding.** `recordVoiceUnderstandingCost` (`ledger/voice-cost.ts`) builds a `voice_understanding` record with the same
`buildModelCallCost` and price book as a turn round (cache reads included), and projects it with the same `projectTurnCost` to the ledger row
`turn#voice:{uuid}` and the cost index item `voice#{uuid}` with `channel_id`, the member in `prompt_author_id`, `kind: voice_understanding` and no
`bot_id`. A failure to record is reported through `reportCostFailure` (an error log line `voice_cost_not_recorded` by default) and never blocks the line.
`recordVendorSpend` and the frozen-first-write arithmetic are removed; nothing writes a ledger row by addition any more.

**Rollups.** The rollup row has `unpriced_calls` (calls with no price in the book that day; their dollars are not in `vendor_cost_usd`), a
`vendor_cost_unpriced_calls` warning is logged when it is positive, and the threshold alert payload carries the month-to-date `unpriced_calls`.

**IAM.** The computer owner role wrote `{tenant}#cost#*`, which would have let a container write the gateway's evidence. The statement now names only
month partitions (`{tenant}#cost#????-??`); `{tenant}#cost#observed` is written by the Front Door and the rollup Lambda only. This is a role-policy change:
the CDK reads `conversation/src/gateway/owner-role-policy.json`, so the stack must be deployed and the owner role verified with the IAM simulator before
an owner image that depends on it is released (moto does not enforce IAM). The scenarios in `owner_role_policy.feature` prove the policy file; they are not
a substitute for the simulator run.

**Still unmetered or approximate.** A Lambda owner calls the vendor directly and has no gateway, so a call it made that the session never committed is
not recoverable. A response that failed or was cut off before the provider counted anything is recorded with zero tokens; only a computer owner's gateway
observation can add its estimate. Compaction spend is attributed to the turn that derives it, which may be the next turn. Sub-agents and forks (T6). The
`pi.usage` `tools` bucket. Service-tier pricing. The estimate of an interrupted stream counts hidden reasoning tokens as zero.

**Proof.** `features/cost_complete_metering.feature`, `cost_gateway_observations.feature`, `cost_gateway_reconciliation.feature`, the voice and rollup
scenarios in `voice_messages.feature` and `daily_budget_rollup.feature`, and the policy rows in `owner_role_policy.feature`. Mutation checks are in the
pull request.

### 3.10 As built (T8)

**What it is.** A read-only HTTP API over the cost index of section 3.8 and 3.9, for the web view of T9. It adds no table, no GSI, no
sibling aggregate item and no scheduled job. The wire schemas are zod in `host-protocol/src/costs.ts` (export `@chatticus/host-protocol/costs`),
which the Front Door code and the web both import; the read model is `conversation/src/domain/costs.ts` and the handlers are
`conversation/src/http/routes/costs.ts`. The turn index item gained two attributes for it: `recorded_at` (the time of the turn's first call,
the same instant that picks its month partition, so a date range can be applied) and, on the gateway rows, `cached_input_tokens` (so the cached
share of a call only the gateway saw can be shown).

**Who may read (the answer to open question 4, as decided here).** Every enabled member of the organization, owner or ordinary member, may
read all of the organization's cost, including the per-member split: it is the organization's own spend. A caller who is not an enabled
member of the organization in the path is refused with 403, whatever other organization they belong to; a request with no credential is
refused the same way. The check is the existing `resolveUserPrincipal` (the same one every `/orgs/{tenant_id}/...` user route uses), so
the tenant in the path is the only tenant any read touches: every query names the partition `{tenant}#cost#{yyyy-mm}` of the path's
tenant. A turn or channel id of another organization is simply not found in this one (404). Narrowing this to "owners see everyone,
members see their own prompts" is a change to `resolveReader` and the filters, not to the data; it is left to the owner's decision on
question 4. Every response carries `Cache-Control: private, no-store`, and `X-Content-Type-Options: nosniff`.

**Routes** (all `GET`, all audience `user`, all under `/orgs/{tenant_id}`):

| Route | Answers |
| --- | --- |
| `/costs?from=&to=&bot_id=&channel_id=&member_id=&group_by=&limit=&cursor=` | The organization's totals for the range (the month so far by default) and, with `group_by` of `bot`, `channel`, `member`, `model`, `day` or `month`, the groups, ordered by key (the null group last), one page at a time. |
| `/costs/turns?...same filters...&limit=&cursor=` | The rows of the range, newest first (ties by id), each with its own totals, one page at a time, plus the totals of the whole range. |
| `/channels/{channel_id}/cost?from=&to=&bot_id=&member_id=&limit=&cursor=` | The session total of a channel (the whole lookback by default), its split by bot, and its rows newest first. |
| `/channels/{channel_id}/messages/{seq}/cost` | `triggered` (the rows whose `prompt_message_seq` is the message: what a member message caused) and `produced` (the rows whose `message_seq` is the message: what a bot reply cost), each with totals and rows. |
| `/turns/{turn_id}/cost` | The turn's index row and, as the drill-down, every call the session recorded for it: round, model, provider, response id, the five token counters, price book id, tier, applied rates, labels, dollars, `unpriced`. |
| `/costs/export?format=csv\|ndjson&grain=turn\|call&from=&to=&filters` | The rows of a range as a download. The call grain is one line per model call, read from the sessions; more than 200 turns is refused with `export_too_large` (422). The header row, or each JSON line, carries the price book id, and the response header `X-Cost-Price-Book-Ids` lists them. |
| `/costs/compare?channel_id=a&channel_id=b` (or `turn_id=`) | Two to six channels or turns side by side, in the order asked: totals and normalized metrics (cost per turn, cost per call, calls per turn, cached input ratio). The plan's `channel=`/`turn=` spelling became `channel_id=`/`turn_id=` to match the rest of the API. |

Errors are `{ "error": code, "message": text }` with `invalid_query` (400: a malformed or impossible date, an inverted range, a start before
the lookback, an unknown `group_by`, a `limit` of zero or above the page maximum, a cursor this route did not issue, a bad format, a compare of
fewer than two or more than six, or both or neither of `channel_id` and `turn_id`), `channel_not_found` and `turn_not_found` (404), and
`export_too_large` (422). A refusal of the caller keeps the `{ "detail": text }` body of every user route. Limits: rows 50 by default and 200 at
most, groups 100 and 500, a lookback of 36 months, a date range of whole UTC days (`yyyy-mm-dd`, both ends included, the day of the row's `recorded_at`).

**The shape of a figure.** One `totals` object is used at every level (a turn, a message, a channel, a bot, the organization, a month):
`rows`, `turns`, `calls`, the four token counters (`input_tokens` is uncached input, as in the records), `priced_cost_usd`, `cost_usd`,
`unpriced_calls`, `complete`, `gateway_rows`, `estimated_rows`, `open_turns`, `price_book_ids` and `by_model` (calls, tokens, dollars
and unpriced calls per model). Dollars are plain decimal text with no trailing zeros and are added exactly (decimal, never floating point).
`priced_cost_usd` is the dollars of the calls that have a price and is the only dollar field that adds up across parts: the groups of any
`group_by` sum to the total of the same filters, the rows of a channel sum to its total, and the calls of a turn sum to the turn.
`cost_usd` is that same figure when every call was priced, and **null when `unpriced_calls` is positive**, with `complete` false, because the true
total is then not known; an unpriced call is never counted as zero dollars. The model subtotal of a turn that has an unpriced call of that
model has no dollars at all (the index keeps one subtotal per model per turn), so the priced dollars of that model within that turn are not in
the sum; `unpriced_calls` is what tells the reader to look. Grouping by `model` splits a row across the models that served its calls, so the dollars,
tokens and calls still add up and only the `rows` counts overlap. A row that is `running`, `yielded` or `parked` is counted in `open_turns`
and its figures may still grow.

**Labels.** Every row says `source` (`session`, derived from the session's call records, or `gateway`, a call only the gateway observed),
`estimated` (a gateway row whose figures are the gateway's estimate of an interrupted stream), `row_kind` (`turn`, `voice_understanding` or
`gateway_call`), `status` and `owner_kind` (the last writer of the turn). The totals carry `gateway_rows` and `estimated_rows`, and the price
book ids that priced the rows, so a change of price book inside a range is visible. A gateway row has no bot, channel or member; it appears in
the null group of `bot`, `channel` and `member` groupings and is excluded by any bot, channel or member filter. The wording the web uses is the
plan's: model token cost at published list prices, not a bill.

**How reads are bounded (the decision of the section 3.2 open point).** A range reads the month partitions it touches, one consistent
`Query` per month, no scan, in parallel; filters, grouping, sorting and paging happen over those rows in the Lambda. There is no GSI by channel
or bot and no sibling aggregate item: at household scale a month holds hundreds of turn items, a channel total reads at most the 36 partitions of
the lookback (empty months are one empty query), and a sibling aggregate would have to be added to the single transaction of `projectTurnCost`
and kept exact under retries for no measured gain. This is the thing to revisit if a deployment ever has a month with tens of thousands of turn items:
the sign is a `/costs/turns` or channel request that takes seconds, and the fix is a sibling item `{tenant}#cost#channel#{channelId}` written in that same
transaction. A turn's drill-down probes the month partitions back from the current one with point reads (the month is not on the turn) and reads the call
records from the bot's session for the channel without owning it. The session read needs the Front Door's read access to the Conversations table and the
PiSessions bucket, which it already has for the message listing.

**The same dedupe rule as the rollup.** A gateway row whose call a session row of the same months lists among its response ids is dropped on
read (`withoutAccountedGatewayRows`), so a call is never counted twice even before the reconciliation has deleted the gateway row. A session row in
the previous month than its gateway row would not be seen as the match; that is rare (a call across midnight of the last day of a month) and the reconciliation
withdraws such a row anyway.

**What is not here yet.** Cost per tool call and per sub-agent in the compare metrics wait for T6 (the records carry no tool call ids and there is no
sub-agent accounting yet); the drill-down reads the root conversation of the bot's session only. The legacy ledger rows are not read (T7 migrates them); this API
reads only the cost index.

**IAM.** No change. The Front Door function already has `grantReadWriteData` on the messaging table (which covers the `{tenant}#cost#*` partitions
for a `Query` and `GetItem`) and read on the Conversations table and the PiSessions bucket (`infra/lib/control-plane-stack.ts`). Moto does not enforce IAM,
so the first real call after deploy is the check.

**Proof.** `features/cost_api.feature` and `features/cost_api_gateway_rows.feature` (the steps are `conversation/features-support/steps/cost-api.steps.ts`) create
turns through the real executor path and read them back over HTTP; `conversation/test/cost-read-model.test.ts` covers the pure helpers (decimal text, accumulation,
grouping, dedupe, windows, cursors, metrics, CSV). Mutation checks on the member check, the tenant partition, the unpriced handling and the gateway dedupe each fail a
named scenario (see the pull request).

### 3.11 As built (T9)

**Where it lives.** The workspace is one static-export page (`/chat`), so the cost view is not a route. It is a third tab of the inspector,
`Context | Files | Costs` (`web/lib/inspector-tabs.ts`), which is already a right-hand column on wide screens and a sheet on narrow ones, so
the view works at 375 px with no new layout. Two things outside the tab open it: a **cost chip** in the conversation header (the open
conversation's total; it opens the tab scoped to that conversation) and a **cost marker** under each message in the thread (`Triggered $x` under a
member message, `Took $x` under a bot reply; it opens the model calls of the turn behind it, or a list of turns when several are behind it).

**What it shows.** Whole organization for a month (the current month so far by default; the previous and next month buttons read a whole month with
explicit `from` and `to`, back to the 36-month lookback): the headline total with its turn and call counts, the notes (turns still running, estimated
rows, rows only the gateway saw, more than one price book), the breakdown by bot, by conversation and by model, the recent turns newest first with a
page at a time (`Show more turns`, using the API's cursor), and two downloads (`Download turns as CSV`, `Download calls as CSV`). `This conversation`
switches the same view to the open conversation's route (`/channels/{c}/cost`: all-time total, split by bot and model, its turns). A turn row opens the
drill-down: the turn total and one card per model call (model, tokens in, cached read, cache write, out, reasoning when present, tier, price book id, round
or kind, dollars or `unpriced`). The export is an authenticated fetch handed to the browser as a file (a bearer-token route cannot be a plain link); the
file name is the API's `Content-Disposition`. Compare is not built here: that is T10.

**Honest states.** A total is written as a plain amount only when the API says `complete` and gives a `cost_usd`. Otherwise it reads `at least $x` and carries
`incomplete: N unpriced call(s)`, in the headline, in every breakdown line, in a turn row, in the chip and in the markers. An unpriced call shows the word
`unpriced` where dollars would be, never zero. Rows are labelled `estimated`, `gateway`, `voice`, `running`, `yielded`, `parked` or `failed`. A turn whose
session could not be read says `The model calls of this turn could not be read.` and keeps its total. A month with no rows says `No model spend recorded for <month>.`.
A refusal (401 or 403) says `Only members of this organization can see its costs.` and shows no figure; any other failure says `Could not load the costs. Try again.`
with a retry; an API error code with its own message (a 4xx the read model words) shows that message. The basis is always on screen: `Model token cost at published list prices. This is not a bill.`

**The money rule** (`formatUsd` in `web/lib/costs.ts`, decimal text only, never a float):

* a call or a turn row is exact to seven places, trailing zeros dropped down to four: `$0.0001852`, `$0.5000`;
* a total (headline, breakdown line, chip) is rounded half up to cents from one dollar up (`$1,234.57`), to four places from `$0.0001` to under a dollar (`$0.0134`),
  and to three significant digits below `$0.0001` with at most seven places (`$0.0000185`), so spend that exists is never shown as `$0.00`;
* only an exact zero reads `$0.00`; a nonzero amount under a ten-millionth of a dollar reads `less than $0.0000001`;
* a marker adds its rows exactly in decimal (`addDecimalTexts`) and uses the call precision.

**Per-message cost** costs one request, not one per message: the chip loads `/channels/{c}/cost?limit=200`, and the markers are worked out from the rows' `prompt_message_seq`
(what a member message triggered) and `message_seq` (what a bot reply took). A conversation with more than 200 turns marks only the loaded rows until `Show more` is chosen
in the tab. The `/messages/{seq}/cost` route is not used by the web.

**Code.** `web/lib/costs.ts` (pure: money, figures, notes, months, breakdown lines, markers), `web/lib/costs-api.ts` (the routes; types only from `@chatticus/host-protocol/costs`),
`web/lib/costs-controller.ts` (no React), `web/components/workspace/CostsPanelView.tsx`, `CostsPanel.tsx`, `CostMarkers.tsx`, wired in `EnabledWorkspace.tsx` and `ChatticusAssistantThread.tsx`.

**Proof.** `features/web_costs.feature` (26 scenarios; steps `conversation/features-support/steps/web-costs.steps.ts`, harness `web/test-support/costs-harness.ts`) runs the real controller and views
against `web/test-support/costs-fake.ts`, a fake of the cost routes that parses everything it sends with the zod schemas of `host-protocol/src/costs.ts`. `web/lib/costs.test.ts` covers the pure helpers.
Mutation checks (show an incomplete total as complete; show an unpriced call as zero; drop the 403 wording; round totals to cents; swap triggered and produced; drop the estimated label; drop the cursor)
each fail a named scenario (see the pull request).

### 3.12 As built (monthly model spend ceiling)

**What it is.** The model gateway refuses a call when the organization's model spend for the current calendar month (UTC) has reached the
ceiling, so a runaway bot cannot burn the vendor's money without limit. It is separate from `monthlyAwsSpendCeilingUsd`, which meters AWS spend.

**The ceiling.** `DEFAULT_MONTHLY_MODEL_SPEND_CEILING_USD`, 25.00 US dollars per organization per month, in every environment
(`conversation/src/ledger/model-spend.ts`). A deployment overrides it with the environment variable `CHATTICUS_MONTHLY_MODEL_SPEND_CEILING_USD` on the Front Door
function; a value that is not a positive decimal stops the function at start instead of removing the limit. It is not yet a per-organization field set by an
owner like the AWS ceiling: that needs an organization read in the gateway, a PATCH route and a web form (follow-up).

**The check.** After the token, turn, size and body checks and before the call is forwarded, the gateway reads the month's cost index partition
`{tenant}#cost#{yyyy-mm}` (one consistent query, the same read the cost API uses) and adds the priced dollars of every row: turn rows and the gateway rows
reconciliation adds. Spend at or above the ceiling is refused with status 403 and the body
`{ "detail": ..., "error": { "type": "model_spend", "code": "model_spend_ceiling_reached", "message": ... } }`. The request never reaches the vendor; the gateway
logs a `refused` event with reason `model_spend_ceiling_reached`. Pi does not retry a 403, so the turn fails at once, and `classifyModelFailure` words it for the member:
"The organization has reached its monthly model spend ceiling, so no more model calls are made until next month." A new month reads a new partition, so the count
starts again by itself, and another organization's partition is never read.

**Accuracy.** The figure is only as fresh as the cost index. A turn writes its row when it records its cost (a resumed attempt, a park, a yield, completion or failure),
not after every call, and the gateway's own observations are not in the index until reconciliation turns them into rows. A call that starts under the ceiling may finish
over it, and one long turn can pass the ceiling before its row is written. Many short turns, the usual shape of a runaway routine, are stopped within one turn of the
ceiling. The check is a guard, not an exact meter.

**Failure of the read: closed.** If the spend cannot be read, the call is refused with status 503, error code `model_spend_unavailable`, and a `refused` log event.
The gateway already needs the same table to read the turn on every call, so a table that cannot be read leaves no availability to protect, and 503 is retryable
so a passing fault does not fail the turn at once.

**IAM.** No change. The Front Door function has read and write on the messaging table, which already covers the `{tenant}#cost#*` partitions for the cost API.

**Proof.** `features/model_gateway_spend_ceiling.feature` (under, at, summed, other organization, month rollover, unreadable spend) and the `model_spend_ceiling_reached` row of
`features/model_provider_failures.feature`; `conversation/test/model-spend-ceiling-config.test.ts` for the setting. Mutation checks (`>=` to `>`, read another tenant's
partition, read a fixed month, drop the failure wording) each fail a named scenario.

**Follow-ups.** A per-organization ceiling an owner can set; a Costs tab line showing the ceiling and the month's spend; counting the current turn's unreconciled gateway observations.

## 4. Work split and dependencies

| Id | Work | Depends on | Files (principal) |
| --- | --- | --- | --- |
| T1 | Failing scenario for F1 (computer owner counted twice) plus decision record: single writer | none | `features/cost_call_records.feature`, steps under `conversation/features-support/steps/` |
| T2 | S0 spike: pi-durable `afterResponse` document write, `pi.usage` reading, child-conversation enumeration; write findings to this doc | none | `docs/COST_ACCOUNTING.md` (spike notes), throwaway code outside `conversation/src` |
| T3 | Versioned price book with cache, tiers, source; fail-closed unpriced; supersedes `chatticus-12ba7f` | none | `conversation/src/ledger/vendor-prices.ts`, `ledger/vendor-ledger.ts`, `gateway/model-gateway.ts`, `turn/openai-models.ts` |
| T4 (done, section 3.8) | Call records in the session (`chatticus.cost` doc), idempotent keyed writes, turn index, retire delta counters and gateway ledger writes (fixes F1, F3, F5) | T1, T2, T3 | new `conversation/src/ledger/call-cost.ts`, `turn/finalize.ts`, `turn/executor.ts`, `gateway/model-gateway.ts`, `store/turn-store.ts`, `computer/host/src/owner-deps.ts` |
| T5 (done, section 3.9) | Complete metering: compaction, aborted, voice with member and channel, gateway observations and reconciliation job | T4 | `voice/understanding.ts`, `domain/voice-messages.ts`, `gateway/model-gateway.ts`, new `conversation/src/ledger/reconcile.ts` |
| T6 | Sub-agent and fork accounting plus parity guard and AGENTS.md rule | T4 (spike T2) | `conversation/src/turn/finalize.ts`, `storage/indexed-storage.ts` queries, guard test under `conversation/test`, `AGENTS.md` |
| T7 | Migrate legacy ledger rows to the turn index; rollup reads the new shape | T4 | `conversation/src/budget/runner.ts`, `budget/budget-store.ts`, `conversation/src/migration/` |
| T8 (done, section 3.10) | HTTP cost API (channel, turn, org rollup, compare, export) | T4, T7 | `conversation/src/http/routes/costs.ts`, `http/app.ts`, `domain/costs.ts` |
| T9 (done, section 3.11) | Web: channel chip, message markers, Costs tab | T8 | `web/lib/costs.ts`, `web/lib/costs-controller.ts`, `web/components/workspace/CostsPanelView.tsx`, `CostMarkers.tsx` |
| T10 | Web: compare view and export | T8, T9 | `web/components/CostCompare.tsx`, `web/lib/api.ts` |

T1, T2, T3 start in parallel. T4 is the pivot. T5, T6, T7, T8 fan out after T4. T9 then T10 follow T8. Each ticket in Kanbus carries its exact files and acceptance check.

## 5. Open questions for the owner

1. **Sole writer.** Agree that the owner derives dollars from the session and the gateway becomes a meter-and-enforce point that only writes reconciliation observations? The alternative (gateway authoritative, session cross-checked) records vendor-seen truth for aborted calls but ties the record to the Front Door instead of the session, which cuts against "rides with the session".
2. **Price source of truth.** Is OpenAI's published price page the contract, with a recorded retrieval date per price book? Who verifies it and how often (manual on a ticket, or a scheduled check that fails when it drifts)? Today's book comes from LiteLLM.
3. **What counts as direct cost.** Model tokens only (the vision), or also computer time per turn (Fargate seconds), snapshot storage and transfer, and Cognito/Lambda? This plan covers model tokens; AWS is metered separately by Cost Explorer and the rollup.
4. **Who can see what.** Organization owners see all costs; do ordinary members see their own prompts, channels they are in, or nothing? Are per-member costs acceptable to show to other members?
5. **Unpriced behavior.** Fail the turn closed (this plan's default), or run and flag? Closed is safer for transparency and worse for a model rollout.
6. **Comparison unit.** Is comparing two channels enough, or should a session carry a free-form "way of working" label (for example "sub-agents per task") that the compare view groups on? A label is a new product surface.
7. **Retention.** Keep call-level records forever (they ride in the session, so they live as long as it does) or cap and keep only turn rollups after N months? Export on demand is assumed either way.
8. **Currency of the claim.** The marketing site may say Chatticus measures model token cost per turn for operators today. It must not say that members can see it until T8 and T9 ship. Confirm that wording boundary.
9. **Ceilings.** Should per-turn or per-session dollar ceilings in the gateway (G7) ride in this epic or stay a budget-epic item (`chatticus-2e3e94`)?

## 6. Kanbus

Epic and children are created on the board in a separate commit to `develop` (project management, no pull request). Their ids are listed in the pull request description and in the audit report; look them up with `kbs list --search "cost accounting"`.
