# ADR 0026: Usage Fact Ledger ## Status Accepted. ## Context ADR-0022 pins the cost-attribution contract for the platform: token, provider-reported cost, run count, and duration are attributed by Organization, Project, Run, model, and Provider Connection; "missing provider cost remains unknown rather than zero." ADR-0021 scopes usage accounting as operational reporting, not payment collection (commercial billing stays deferred). The implementation today stores this as **one scalar per `AgentRun`**: `inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is adequate for a single provider-reported model loop, but it cannot represent: - **External capability consumption** inside a run. PDF→Markdown bundle conversion, audio/video transcription, OCR and similar media transforms are not the main agent loop. They run as side effects of a run, may use a different provider/model, may bill in non-token units (pages, seconds, invocations), and may report cost through a different channel than the OpenRouter gateway. Today there is no row to write that cost to — it would either disappear or silently corrupt the run's single scalar. - **Multiple model calls within one run** (e.g. a sub-model invoked by a tool, a gateway-side reroute). The scalar collapses them into one number. - **Pricebook derivation** after the fact. With only a final USD figure and no `(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive cost from a price table when the provider did not report it. Treating each external call as a nested `AgentRun` was considered and rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role binding (ADR-0017), admission/capacity semantics (ADR-0022), and the user-visible task boundary. External calls hold none of those. Making them `AgentRun`s would pollute run counts, admission, lock semantics, and session continuity, and would still not solve non-token metering. ## Decision Introduce **`UsageFact`** as the single source of truth for billable consumption inside an `AgentRun`. An `AgentRun` owns zero or more append-only `UsageFact` rows; each row records one billable consumption event: - `kind` — `model_completion` (the main agent loop) | `external_capability` | `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not silently folded into an existing one. - `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`. - `model?`, `inputTokens?`, `outputTokens?` — token metering, optional because non-token capabilities have none. - `quantity?` + `unit?` — non-token metering (pages, audio_seconds, invocations), coexisting with tokens rather than replacing them. - `costUsd?` + `costSource` — `provider_reported` | `pricebook_derived` | `unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the provider reported a cost, `costSource = provider_reported` and that value wins. When only tokens are known, a later pricebook pass may derive `costUsd` with `costSource = pricebook_derived`. When neither is possible, `costSource = unknown` and `costUsd` stays null. - `occurredAt` — when the consumption happened; the pricebook derivation depends on this, not on `AgentRun.finishedAt`, because an external capability may complete before the run finishes. - `capabilityId?` — for `external_capability` facts, the registered capability id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`). - `correlationId?` — external request id for reconciliation / idempotency; not part of the aggregation key. ### Invariants 1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost correction is a new row; the old row stays. `onDelete: Cascade` exists only so a hard run delete (itself not a normal path) cleans up its facts. 2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect ledger of one run, not a sub-run. External capability calls obey the same boundary: their identity is `capabilityId + correlationId`, not `RunId`. 3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0. A run whose facts all have `costUsd = null` is "cost unknown" — reported as `runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are today. A run with at least one `costUsd`-bearing fact contributes its sum. ### Rollup cache `AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept as a **derived rollup cache**, not dropped: - Existing non-`/usage` readers (slash `/usage`, session detail, integration tests asserting `run.costUsd`) continue to work without code changes for historical runs. - On run finish, the writer writes the `UsageFact` row first and then mirrors it onto `AgentRun` as two separate statements, not one transaction. The fact is the truth, so it is written first; the cache is derived, so it is written second. A crash between the two leaves the cache stale, but the usage service re-reads `UsageFact` directly, so staleness is recoverable (and the reverse order would lose the truth, which is not). Keeping the fact insert and the run update in separate statements also avoids holding the `AgentRun` row lock across the FK ShareLock taken by the insert, which deadlocks against concurrent workspace teardown under the cascade path `Organization → Project → AgentRun → UsageFact`. - The org/project usage service and the slash `/usage` command read from `UsageFact` directly — they are the canonical aggregation path. ### Migration A new `UsageFact` table is added. For every existing `AgentRun` with a non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration inserts **one synthetic `model_completion` fact** carrying the run's provider, model, tokens, cost, and `costSource`. Its `correlationId` is the run id, so the row is identifiable as a backfill artefact. This keeps historical reporting correct under the new aggregation path. Pre-migration runs with no recorded cost remain `runsWithoutCost` by design, matching ADR-0022's "missing cost ≠ zero" rule and the existing migration `20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the truly unrecorded. ## Consequences - The billing model now supports external capabilities (PDF→MD, ASR, OCR, …) without per-capability schema changes: a new capability is one new `capabilityId` value and one or more `external_capability` facts. - `AgentRun.costUsd` is no longer the truth; it is a convenience cache. Readers that need the truth (cost breakdown, per-capability attribution) must read `UsageFact`. The cache MUST be kept consistent on the write path. - The capability registry, pricebook, and any commercial settlement remain `OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger shape and invariants. - `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and `(provider, model, occurredAt)` keeps the existing org/project/report queries bounded. - Cost corrections (e.g. a provider rebills a run) produce a new fact row; the rollup cache must be recomputed. The initial release writes facts once at run finish and does not support post-hoc correction flows — that remains `OPEN`. ## Deferred - **Capability registry** as a first-class spec entity (`Spec.System.Capability`) with org-scoped enable/disable, input/output contracts, metering schemas, and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves the `capabilityId` field and the `external_capability` fact kind. - **Pricebook** — a versioned price table keyed by `(provider, model, unit)` with time validity. Required to actually produce `pricebook_derived` costs; until then, facts without provider-reported cost stay `unknown`. - **Post-hoc cost correction flow** — append-only today; correction UI and rollup recompute are `OPEN`. - **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.