# How a dollar gets a name

A row's attribution comes from three authorities. Two can be set by anyone who can call your gateway, and it matters which is which.

Source: https://tokentriage.com/docs/build/cost-attribution

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';
import CostAttributionDiagram from '../../../components/CostAttributionDiagram.astro';

A total is only as useful as the ways you can split it. `$61,000 last month` prompts one
question and answers none. `$44,000 of it was one tenant's nightly summarisation job` ends the
meeting.

Getting from the first sentence to the second means every request has to arrive carrying enough
identity to be filed under a name you recognise. This page is about where those names come
from, which of them you can rely on in an argument about money, and which of them anybody who
can reach your gateway can write whatever they like into.

You want [turn on business dimensions](/docs/build/cost-dimensions) if you are here to do the
configuration. This page is the model behind it.

## Nothing is inferred

TokenTriage will not guess. A request that arrives with no identity headers produces a row with
no tenant, no feature and no user, and its spend lands in an `unattributed` bucket that is
rendered as its own first-class row rather than distributed across the named ones.

That is a deliberate trade and it costs something. A gateway that inferred a tenant from the
source IP, or stitched a conversation from timing gaps, would produce a fuller-looking
breakdown. It would also produce a breakdown that is wrong in ways nobody can see, and a cost
allocation nobody can see the errors in is one you cannot defend when a team disputes their
share. So attribution comes only from explicit identity on the wire, and a gap stays visible as
a gap.

## Three authorities, not one

A row's dimensions come from three places, and the difference between them is the difference
between evidence and a claim.

<CostAttributionDiagram />

Read it left to right. The request arrives carrying whatever headers the client chose to set.
TokenTriage stamps two dimensions itself, reads four more from headers, and folds most of them
into the hourly pre-aggregation on the right, leaving out the two whose value space has no
bound.

### Stamped: `dim.team` and `dim.key`

When a request authenticates with a [virtual key](/docs/build/governance-keys), the proxy
records the team node that key belongs to and the key's own id. Neither is read from the
request. A client that sends `x-tt-team: finance` gets that header stripped and ignored; the
value on the row is the one the server resolved.

This is the attribution you can take to a billing dispute. It cannot be spoofed by the caller,
because the caller never supplies it. It follows from which credential was presented, and
changing it means being issued a different key.

### Asserted: `dim.tenant`, `dim.feature`, `dim.user`, `dim.tag.<key>`

These come from headers the client sets, and the client can set them to anything.

| Header | Dimension | Cap |
|---|---|---|
| `x-tt-tenant` | `dim.tenant` | 128 bytes |
| `x-tt-feature` | `dim.feature` | 128 bytes |
| `x-tt-user` | `dim.user` | 128 bytes |
| `x-tt-tag-<key>` | `dim.tag.<key>` | key 32 bytes, value 128 bytes, 8 tags |

That is not a flaw. Your application is the only thing that knows which feature a request came
from, so asking it is the only way to find out. But it means an asserted dimension is a claim by
your own code, and it is worth as much as your code's discipline about setting it. TokenTriage
marks the distinction rather than smoothing it over: every chargeback row carries an `asserted`
field, and a row built from a client-supplied dimension does not get to look like one the server
stamped.

<Callout type="warning">
An asserted dimension is only as trustworthy as the callers who set it. If tenants call your
gateway directly rather than through your own service, a tenant can bill its usage to another
tenant by changing one header. Issue a virtual key per tenant and attribute on `dim.team`
instead, which no caller can write. [Multi-tenancy
patterns](/docs/build/governance-tenancy-patterns) covers the key-per-tenant shape.
</Callout>

### Derived: what the decision produced

Four more dimensions come from the request's own handling and need no client cooperation at
all: `decision.model_served` (the model actually served, after any rewrite), `decision.tier`,
`http.provider` and `http.endpoint`. These are always present, on every install, with nothing
turned on. They are why a fresh deployment can still tell you that spend concentrated in one
model on one provider, and why it cannot tell you whose it was.

## Identity versus attribution

The `x-tt-*` namespace carries two different families of header and they answer different
questions. Mixing them up is the most common source of "I set the headers and the breakdown is
still empty".

| Family | Headers | Answers | Where it is documented |
|---|---|---|---|
| **Identity** | `x-tt-trace-id`, `x-tt-step-id`, `x-tt-parent-id`, `x-tt-agent`, `x-tt-step-class` | which run, which step, which agent | [what identity is on the wire](/docs/build/observability-identity) |
| **Business dimensions** | `x-tt-tenant`, `x-tt-feature`, `x-tt-user`, `x-tt-tag-<key>` | whose budget this comes out of | this section |

Identity headers work out of the box. Business dimensions need
`ledger.dimensions.enabled: true`, which is off by default. With the flag off nothing is
extracted, whatever the client sends, and every `dim.*` reads as absent everywhere including in
routing rules.

Both families are stripped from the request before it is forwarded, so your provider never sees
them.

## Working the trace tree

Identity headers earn their keep when the question is *which step of which agent*, rather than
*whose budget*. With `x-tt-trace-id` and `x-tt-step-id` set, one conversation collapses into a
costed tree:

```console
$ tokentriage top --trace tt_00000000000000000000000001
trace tt_00000000000000000000000001  (1 steps)
STEP                           CLASS      AGENT         MODEL                      COST       IN     OUT  CACHE-R
tt_00000000000000000000000001  user_turn  batch-runner  anthropic/claude-opus-4-8  $0.100000  20000  0    0
TOTAL                                                                              $0.100000  20000  0    0
```

That capture is from `tokentriage demo`, whose synthetic traces are single-step, so the tree is
one row deep. A real agent that sets `x-tt-parent-id` on its delegated calls renders as an
indented tree with each sub-agent's subtotal underneath it.
[Attribute cost per agent step](/docs/recipes/attribute-cost-per-agent-step) is the recipe.

## What can go wrong

**You turned dimensions on and last month is still unattributed.** Dimension extraction applies
to rows as they are written. Turning the flag on does not reach back into rows that were logged
without it, and no rebuild will recover a header nobody sent. Turn it on before you need it.

**One team's spend is spread across four spellings of its name.** Nothing normalises an asserted
dimension. `finance`, `Finance` and `finance-team` are three tenants as far as the ledger is
concerned. Decide the vocabulary in your own code, ideally in one place that all your services
call.

**A tenant's spend vanished into `(overflow)`.** The two asserted dimensions that are in the
hourly pre-aggregation, `dim.tenant` and `dim.feature`, are capped at 256 distinct values per
hour to stop one caller making the rollup table grow without limit. Past the cap, values fold
into a labelled `(overflow)` bucket, chosen by request volume so your real traffic stays
addressable. If you legitimately have thousands of tenants an hour, see
[what the rollups make cheap](/docs/build/cost-rollups#the-cardinality-cap).

<Details summary="Why dim.user is not in the pre-aggregation, and never will be">

The hourly rollup materialises one cell per hour per combination of its eight dimensions. Every
one of those eight is bounded by something: `dim.team` and `dim.key` by the keys you issue,
`model_served`, `tier`, `provider` and `endpoint` by your configuration, and `dim.tenant` and
`dim.feature` by the cardinality cap.

`dim.user` has no such bound, since a consumer product has as many users as it has users, and
neither does the free-form tag namespace. Pre-aggregating them would make the rollup table grow
with your traffic rather than with your configuration, which is the failure mode the
pre-aggregation exists to avoid.

So a per-user question scans rows every time. It still answers, and the answer is identical; it
only costs a scan. This is a permanent property of the design rather than something a later
release tunes away.

</Details>

<NextSteps items={[
  {title: "Turn on business dimensions", href: "/docs/build/cost-dimensions", description: "The config, the headers to set, and how to check a row picked them up."},
  {title: "What the rollups make cheap", href: "/docs/build/cost-rollups", description: "The eight-dimension pre-aggregation, the six clauses that decide whether it is used, and the questions it cannot answer."},
  {title: "Virtual keys", href: "/docs/build/governance-keys", description: "Issue a key per team or tenant so attribution is stamped by the server rather than asserted by the caller."},
  {title: "Decision ledger fields", href: "/docs/reference/ledger-fields#dims", description: "The dims block field by field, with types and when each is absent."},
]} />
