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.
On this page
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 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:
$ 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.
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.
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:
$ 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.
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.
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:
# 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:
{"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.
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.
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:
$ 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:
$ 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.
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:
$ 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:
$ 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
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 own that. Sizing the rest of a deployment, including what the chart suggests for CPU and memory, is on sizing and 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 covers the read-time merge over them.