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 singletrack()call.parent_call_id— optional linkage to an enclosing envelope. Supplied explicitly by the caller or inferred from the activeCostScope(see below). When non-Nonethe record is a child.dedupe—"child"for records with aparent_call_id, otherwiseNone.CostTracker.get_total()/get_by_capability()/total_usd()/total_tokens()skip records withdedupe == "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 withasyncio. - 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
CostScopeper 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
CostScopeshould be rolled intoContextasctx.cost_call_id: deferred — thecontextvarsapproach avoids touching the context/runtime layers this sprint.