Skip to content

ADR-0012a: Cost envelope nesting — call_id / parent_call_id / dedupe

  • Status: Accepted
  • Date: 2026-04-14
  • Amends: ADR-0012

Context

ADR-0012 made cost envelopes a framework primitive: every capability invocation opens one, every LLM call opens one. When agents arrived (M9), planners began opening an outer envelope per step while the capability they dispatched continued to open its own envelope, and the LLM call inside that capability opened a third. With three concurrent envelopes billing the same tokens, aggregate totals were 2×–3× the true cost. The issue was flagged by M9 Phase 1 (3653c97) and re-flagged by Phase 2 / 3 (ec81c0b, a5eced0).

Flat deduplication (e.g. "only keep the last envelope touching this model") loses audit granularity: auditors need to know the LLM call happened, how many tokens it consumed, how long it took. We want the hierarchy preserved and the totals deduped.

Decision

Extend cost records with three correlation fields. Preserve all records for audit; exclude "child" records from totals.

  • call_id — UUID stamped on every record. Auto-assigned when the caller omits it. Stable across a single track() call.
  • parent_call_id — optional linkage to an enclosing envelope. Supplied explicitly by the caller or inferred from the active CostScope (see below). When non-None the record is a child.
  • dedupe"child" for records with a parent_call_id, otherwise None. CostTracker.get_total() / get_by_capability() / total_usd() / total_tokens() skip records with dedupe == "child". records() returns them unfiltered so auditors still see full detail.

CostScope — the planner-facing surface

trails.cost.CostScope(tracker, *, capability_id, call_id) is a context manager that parks its call_id on a per-tracker contextvars.ContextVar. While active, any tracker.track(...) call that does not supply parent_call_id inherits the scope's id. Planners open a scope per step; the LLM module's existing track call picks the scope up transparently, so capability authors change nothing.

The planner records the step envelope itself by passing call_id=scope.call_id on its own track() — that record's call_id matches the active scope, so it is NOT treated as its own child and lands in totals as the authoritative billing.

Scopes nest (inner scopes shadow outer ones), are independent per CostTracker instance, and restore the previous scope on exit.

Backwards compatibility

CostTracker.track(...) without the new keyword arguments is unchanged: a fresh call_id is minted, parent_call_id is None, dedupe is None, and the record is counted in totals. The 810+ existing tests pass unaltered.

Budgets

A child record also skips the per-principal budget deduction. Otherwise budgets would hit the limit at 2× real spend. The parent record — the authoritative billing — drives the budget.

Consequences

Positive

  • Planner + capability + LLM envelopes coexist without multiplication.
  • Full audit detail preserved: every level is visible in records().
  • Capability authors do not have to plumb IDs; scopes propagate via contextvars, which also plays well with asyncio.
  • No new dependencies. Purely additive on the existing tracker.

Negative

  • Two new fields on the record plus a dedupe flag; minor memory cost.
  • Scope correctness depends on planners opening a CostScope per step. Planners that don't open one still work (flat behavior, like today).

Open questions

  • Outer-wins vs inner-wins. This ADR picks "outer wins" (parent record is billed, child is annotated). The inverse — inner-wins, where the LLM detail survives and the planner's aggregate envelope is the dedupe target — is occasionally desirable (e.g. when the planner estimated cost and the LLM came in cheaper). Made configurable in a follow-up if real audits demand it; for now the inner record retains full detail even when dedupe="child", so accounting is preserved without double-billing either way.
  • Whether CostScope should be rolled into Context as ctx.cost_call_id: deferred — the contextvars approach avoids touching the context/runtime layers this sprint.