# The decision ledger on disk

One JSON line per request, appended forever. How fast it grows, when it rotates, what gets deleted, and what a reader sees afterwards.

Source: https://tokentriage.com/docs/operate/ledger-lifecycle

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

Every request TokenTriage sees becomes one JSON line on a disk you own. That is the whole
storage model, and it raises the first question any operator asks: how big does it get, and
what stops it.

The answer is three mechanisms that do three different jobs. **Rotation** bounds the file
you are writing to. **Retention** bounds how long the rotated copies survive. The **writer
queue** bounds how far behind the disk is allowed to fall before rows start being discarded
rather than delaying your traffic.

This page measures each one on real output so you can size a volume and set a policy. If you
want the mechanics of the drop counter and the other telemetry, [what a drop
means](/docs/build/observability-drops) owns that; here we cover the file itself.

## How fast it grows

A row's size depends on what the request carried. Every row holds the same fixed skeleton —
identifiers, the routing decision and its explain path, usage, cost with its pricing snapshot,
latency. On top of that sits whatever varies with your traffic: the model name, the number of
messages, tool counts, and any business dimensions you tag.

A concrete figure to start from. Twenty single-message Anthropic completions through a
local mock produced this:

```console
$ wc -c -l data/decisions.jsonl
   20 34620 data/decisions.jsonl
```

That is **1,731 bytes per row**, so roughly **1.7 KB per request** for traffic of that shape.
Your rows will be larger if you send long tool lists or many dimensions, and larger again in
`shadow` mode, which adds the would-route decision to every row. Measure yours the same way
after a day of real traffic rather than adopting the figure above.

<Callout type="key">
At 1.7 KB per row, a million requests is about **1.6 GiB** of ledger. Multiply your daily
request count by your measured row size, then by your retention window, and you have your
volume size. Nothing else on the disk is close.
</Callout>

The default chart PVC is `10Gi`, which holds roughly six million rows at that size. The
compressed history costs far less, as the next section shows.

## Rotation bounds the active file

`ledger.decision_log.rotate_mb` sets the size at which the writer closes the active file,
gzips it under a timestamped name, and starts a fresh one. The default is **256** MiB; a value
of `0` or less turns size-based rotation off entirely and lets one file grow without bound.

```yaml
ledger:
  decision_log:
    path: /data/.tokentriage/decisions.jsonl
    rotate_mb: 256 # rotate the active file past this size; 0 disables rotation
    queue: 8192 # bounded writer queue; overflow drops and counts
```

Rotation is checked after each append, so the active file can exceed the threshold by at most
one row. Driving 2,500 requests through an instance configured with `rotate_mb: 1` left this
on disk:

```console
$ ls -la data/
-rw-r--r-- 1  19668 decisions-20260810T125338-1.jsonl.gz
-rw-r--r-- 1  19588 decisions-20260810T125338-2.jsonl.gz
-rw-r--r-- 1  19635 decisions-20260810T125338-3.jsonl.gz
-rw-r--r-- 1  19647 decisions-20260810T125338.jsonl.gz
-rw-r--r-- 1 159192 decisions.jsonl
```

Two things in that listing are worth reading closely.

**The compression is dramatic.** Each companion holds a full 1 MiB of rows in about 19.6 KB —
a ratio near **53:1**. Decision rows are highly repetitive JSON, so they compress far better
than general data. That is why the retention size cap in the next section is a cap on
*compressed* bytes and why history is cheap to keep.

**The timestamp is second-precision.** All four rotations happened inside one second, so the
first took the plain stamp and the rest took `-1`, `-2`, `-3` suffixes. A collision suffix in
your directory means rotation fired more than once that second, not that anything went wrong.

<LedgerLifecycleDiagram />

Read the figure left to right. The active file is the only thing being written; a rotation
turns it into a compressed companion and opens a fresh one. Retention only ever reaches the
companions, and the dashed boundary around the active file is the line the sweeper cannot cross.

## Retention bounds the history

The top-level `retention:` block deletes rotated companions. Both limits default to zero, and
zero means keep forever.

```yaml
retention:
  max_age: 720h # delete rotated files older than this; 0 = keep forever
  max_total_mb: 512 # cap the total size of all rotated files; 0 = unbounded
```

| Key | Default | What it does |
|---|---|---|
| `max_age` | `0` (keep forever) | deletes a rotated file whose rotation timestamp is older than the duration |
| `max_total_mb` | `0` (unbounded) | after the age pass, evicts the oldest survivors until the compressed total is under the cap |

The sweeper runs **hourly**, and the first sweep happens one interval after start rather than
at boot, so a fresh process does not prune immediately. Each deletion writes an audit row. Here
is a sweep over a directory holding four companions dated across five weeks, with
`max_age: 720h`:

```text
# before:
  decisions-20260701T031500.jsonl.gz                19647 bytes
  decisions-20260715T031500.jsonl.gz                19647 bytes
  decisions-20260801T031500.jsonl.gz                19647 bytes
  decisions-20260809T031500.jsonl.gz                19647 bytes
  decisions.jsonl                                  165342 bytes

# plan: 1 file(s) selected
  decisions-20260701T031500.jsonl.gz                19647 bytes  max_age

# after:
  audit.jsonl                                         266 bytes
  decisions-20260715T031500.jsonl.gz                19647 bytes
  decisions-20260801T031500.jsonl.gz                19647 bytes
  decisions-20260809T031500.jsonl.gz                19647 bytes
  decisions.jsonl                                  165342 bytes
```

And the row it wrote:

```json
{"v":1,"ts":"2026-08-10T12:55:42.0590646Z","actor":"system","role":"","origin":"cli","action":"retention.delete","resource":"decisions-20260701T031500.jsonl.gz","summary":"deleted rotated log decisions-20260701T031500.jsonl.gz (19647 bytes, max_age)","result":"ok"}
```

Those blocks come from a harness that calls the shipped sweeper and the shipped audit writer
directly, because the hourly cadence is not something a page can wait for. The selection, the
deletion and the audit row are the product's own code paths; only the clock is supplied.

The size cap works the same way over whatever survives the age pass. Staging 84 companions
totalling 1,650,348 bytes and setting `max_total_mb: 1` selected the 31 oldest and left 53
files totalling 1,041,291 bytes, under the 1,048,576-byte cap, oldest evicted first.

<Callout type="warning">
`retention:` is only enforced when the dashboard is enabled. The sweeper is started as part of
the dashboard composition, so a headless deployment with `dashboard.enabled: false` rotates
but does not prune, and the companions accumulate until the volume fills. Set
`dashboard.enabled: true` (the Helm chart's default) if you rely on retention, or size the
volume for unbounded history.
</Callout>

<Details summary="Why the active file can never be deleted by retention, whatever you configure">

The sweeper's candidate test is strict, and it is strict because the operation is destructive.
A file is a candidate only if its name matches `<stem>-<timestamp>.jsonl.gz` **and** the
timestamp parses. The active `<stem>.jsonl` matches neither condition, being neither gzipped nor
timestamp-suffixed, so no configuration can select it.

The same strictness protects your own files. A `decisions-backup.jsonl.gz` you copied into the
directory has a leading segment that is not a timestamp, so it is not a candidate and stays
where you put it. A sibling log's companion, such as `decisions-audit-20260701T031500.jsonl.gz` belonging
to a stem of `decisions-audit`, is excluded for the same reason.

The trade is deliberate: a genuinely corrupt timestamp means a file is retained rather than
pruned. Retention errs towards keeping data.

</Details>

## The writer queue bounds the lag

The ledger writer is asynchronous. A request hands its row to a bounded channel and returns
immediately; a single goroutine drains the channel, marshals each row and appends it. When the
channel is full the row is **discarded and counted** rather than making the request wait.
`ledger.decision_log.queue` sets the depth, and the default is **8,192**.

That choice has an operational consequence worth seeing rather than reading about. An instance
configured with `queue: 1` and driven with 5,000 requests produced this:

```console
$ curl -s localhost:9091/metrics | grep declog_dropped
tokentriage_declog_dropped_total 1539

$ curl -s localhost:9091/metrics | grep requests_total
tokentriage_requests_total{dialect="anthropic",mode="ledger",path="/v1/messages",status="200"} 5000

$ wc -l data/decisions.jsonl
3461 data/decisions.jsonl
```

Every one of the 5,000 requests was served with a `200`. 3,461 rows reached the file and 1,539
were dropped, and the two add up to exactly the request count. The traffic was fine; the record
of it was not.

The cost lands on anything computed from the file:

```console
$ tokentriage top --log ./data/decisions.jsonl
MODEL             REQS  INPUT    OUTPUT   COST
claude-haiku-4-5  3461  4153200  1211350  $10.209950
TOTAL             3461  4153200  1211350  $10.209950
```

The instance actually served 5,000 requests worth $14.75 at the same per-request price. The
rollup is short by $4.54 and cannot say so, because the rows that would have carried the
disclosure are the rows that were never written. The counter on the metrics endpoint is the
only place the shortfall exists. That asymmetry, and what to do about each of the four drop
counters, is covered in [what a drop means](/docs/build/observability-drops).

A queue depth of `1` is not a realistic setting; it was chosen to make the drop happen on
demand. At the default of 8,192 the writer has to fall more than eight thousand rows behind,
which in practice means a stalled disk, a full filesystem, or a burst far beyond what the
volume can absorb.

## What a reader sees across a rotation

Rotation splits your history across several files, and the surfaces that read it do not all
treat that the same way. This is the part that surprises people after a restore.

The CLI joins. Given a directory holding one active file of 97 rows and four companions, `top`
reports the whole corpus whether you point it at the file or the directory:

```console
$ wc -l < decisions.jsonl
97

$ tokentriage top --log ./rotdemo/decisions.jsonl
MODEL             REQS  INPUT    OUTPUT  COST
claude-haiku-4-5  2520  3024000  882000  $7.434000
TOTAL             2525  3024000  882000  $7.434000
```

The dashboard's SQLite read-model does not, when it is rebuilt from a file path:

```console
$ tokentriage index rebuild --index ./rotdemo/idx.sqlite --log ./rotdemo/decisions.jsonl
rebuilt index ./rotdemo/idx.sqlite from ./rotdemo/decisions.jsonl
  rows indexed:    97

$ tokentriage index rebuild --index ./rotdemo/idx.sqlite --log ./rotdemo
rebuilt index ./rotdemo/idx.sqlite from ./rotdemo
  rows indexed:    2525
```

<Callout type="warning">
`tokentriage index rebuild` given a **file** indexes only that file. Given the **directory** it
indexes the companions too. Its `--log` default is the configured log *file*, so a bare
`index rebuild` on a rotated install silently drops every rotated row from the dashboard while
the CLI keeps reporting them. Always pass the directory. [Back up and
restore](/docs/operate/backup-restore) covers where this bites and how to check for it.
</Callout>

During normal operation the gap does not appear, because the indexer tails the active file
continuously and has already recorded each row before it rotates. It appears when the index is
rebuilt: after a restore, after a schema bump self-heals, or when you run the command yourself.

## What this does not cover

Nothing here changes what a row *contains*; [the decision ledger
fields](/docs/reference/ledger-fields) own that. Sizing the rest of a deployment,
including what the chart suggests for CPU and memory, is on [sizing and
capacity](/docs/operate/capacity). In a multi-replica fleet each replica keeps its own ledger
on its own volume and everything above applies per replica; [multi-replica &
scale-out](/docs/operate/scale-out) covers the read-time merge over them.

<NextSteps items={[
  {title: "Back up and restore", href: "/docs/operate/backup-restore", description: "Which of these files is the system of record, and an executed procedure for getting them back."},
  {title: "Sizing and capacity", href: "/docs/operate/capacity", description: "Turn the growth rate above into a volume size, and what else the deployment costs."},
  {title: "What a drop means", href: "/docs/build/observability-drops", description: "The drop counter in the wider telemetry picture, and the alert that fires on the first one."},
  {title: "Diagnose a running instance", href: "/docs/operate/diagnostics", description: "Read the ledger's health, row count and torn lines from doctor without stopping anything."}
]} />
