# The usage store — the contract
The second file nazar-tray writes, and the first one that is about **what you used** rather
than **what is left**. `limits.json` answers *how much of my window is gone?*; this one
answers *how many tokens did I actually spend, and on which model?*
It is written here **before** the code exists, because the decision it rests on — that
nazar-tray reads Claude Code's transcripts at all — reverses a decision this repository had
already written down, and a reversal that arrives as a surprise in a diff is not a decision.
The reversal itself is in [`PROJECT.md`](PROJECT.md) §8 and the fields are pinned in
[`pinned-internal-formats.md`](pinned-internal-formats.md); this page is the file that comes
out the other end.
- **Location:** `%APPDATA%\nazar\usage\YYYY-MM.json` on Windows;
`$XDG_CONFIG_HOME/nazar/usage/` or `~/.config/nazar/usage/` elsewhere;
`$NAZAR_HOME/usage/` when that override is set. **One file per UTC calendar month.**
- **Written by:** the nazar-tray process, and only it. One writer, many readers.
- **Written how:** temp file in the same directory, then rename — the same
`crates/nazar-core/src/atomic.rs` that writes `limits.json`. The whole month is rewritten
every time. Nothing in this repository appends to a file.
- **Schema:** `version: 1`. Unknown keys survive a rewrite.
- **Read by:** nobody yet. This document is what would make it safe to read.
## Why it is not in `~/.nazar`
[`limits-contract.md`](limits-contract.md) draws the line and this file lands on the other
side of it: everything a *consumer* reads lives under `~/.nazar`, and the files that are the
**user's own** live where the platform keeps settings — `config.json`, `alerts.json`, and now
this.
Two reasons, and the first is rule 1 of that contract. **`limits.json` is safe to paste into
a bug report**: two percentages and two reset times say nothing about what anyone was doing.
A month of hourly token counts is a usage profile — when this machine works, how long the
sessions are, which model does the heavy lifting. It breaks no rule about credentials, and it
is still not a thing to hand over by reflex, so it sits with the user's own files rather than
in the directory this project tells other programs to read.
The second is scope. **Nazar does not read this file**, and no other program does either. If
Nazar ever should, that is its own work package on both sides — this page is what that package
would be written against, which is the whole reason it exists now rather than then.
**The consequence, stated rather than discovered:** `%APPDATA%\nazar` goes when an uninstall
is told to *delete application data*, and that takes the history with it — the same tick
already takes the settings and the notification log. `~/.nazar` is the directory no uninstall
path touches, and `limits.json` is what lives there. A user who wants the history to outlive
the application copies the directory; a user who ticks the box meant it.
## The document
```json
{
"version": 1,
"month": "2026-09",
"since": "2026-09-07T04:13:52Z",
"scanned_at": "2026-09-13T01:22:09Z",
"providers": {
"claude": {
"buckets": {
"2026-09-13T00": {
"claude-opus-5": { "input": 118, "output": 9412, "cache_create": 184203, "cache_read": 41118902, "requests": 61,
"raw": { "input": 196, "output": 15702, "cache_create": 307201, "cache_read": 68566141 } },
"claude-fable-5-1": { "input": 12, "output": 1877, "cache_create": 24843, "cache_read": 3561302, "requests": 9,
"raw": { "input": 20, "output": 3131, "cache_create": 41438, "cache_read": 5938836 } }
},
"2026-09-13T01": {
"claude-opus-5": { "input": 44, "output": 3110, "cache_create": 61044, "cache_read": 9330112, "requests": 22,
"raw": { "input": 73, "output": 5187, "cache_create": 101807, "cache_read": 15560686 } }
}
}
},
"codex": {
"buckets": {
"2026-09-13T00": {
"gpt-5.6-sol": { "input": 2043, "output": 8801, "cache_create": 0, "cache_read": 1988416, "requests": 14 }
}
}
},
"claude_reported": {
"source": "claude-stats-cache",
"buckets": {
"2026-09-07T00": {
"claude-opus-5": { "input": 0, "output": 0, "cache_create": 0, "cache_read": 0, "requests": 0, "reported_total": 2078342191 },
"claude-fable-5-1": { "input": 0, "output": 0, "cache_create": 0, "cache_read": 0, "requests": 0, "reported_total": 652369779 }
}
}
}
}
}
```
| Field | Type | Meaning |
|---|---|---|
| `version` | integer | `1`. Its own number, unrelated to `limits.json`'s `schemaVersion` and to `config.json`'s. Bumped only by a change that **removes or repurposes** a field; adding an optional one is not breaking, because unknown keys survive. |
| `month` | string | `YYYY-MM`, **UTC**, and the same value as the file name. Written into the document so a file that was renamed or copied still says what it is. |
| `since` | string | RFC 3339 `…Z`. The **earliest instant any bucket in this store came from** — not the earliest in this file. It is what the panel's *since {date}* line reads, and it is the honest boundary of the words "all time": the first scan, plus however far back the transcripts still reached on the day it ran. |
| `scanned_at` | string | RFC 3339 `…Z`. When the scan that produced this document finished. A diagnostic: it answers "is this history being kept up to date" the way `limits.lock`'s heartbeat answers "is the tray alive". |
| `providers` | object | Keys are `claude` and `codex` — the same two spellings `limits.json` uses — plus `claude_reported`, which is not a reader and is described in its own section below. A provider that has never been read has no key at all, rather than an empty object. |
| `providers.
.buckets` | object | Keys are **UTC hours**, `YYYY-MM-DDTHH` (13 characters, no minutes, no offset, no `Z` — it is an hour, not an instant). An hour in which nothing happened is **absent**, never a row of zeroes. |
| `….` | object | The model id **exactly as the source reported it**. Five counters, below, plus the optional `raw` and `reported_total`. |
### The five counters
| Counter | Claude source | Codex source |
|---|---|---|
| `input` | `message.usage.input_tokens` | `last_token_usage.input_tokens` **minus** `cached_input_tokens` |
| `output` | `message.usage.output_tokens` | `last_token_usage.output_tokens` |
| `cache_create` | `message.usage.cache_creation_input_tokens` | `last_token_usage.cache_write_input_tokens` when present, else `0` |
| `cache_read` | `message.usage.cache_read_input_tokens` | `last_token_usage.cached_input_tokens` |
| `requests` | deduplicated assistant messages that carried a `usage` object | `token_count` events counted |
All five are non-negative integers, **always present, and `0` where nothing reported one**.
A bucket never omits a counter and never writes `null`: it is a sum over many records, and a
sum of nothing is zero. (The distinction between *absent* and *zero* is real one record at a
time, and it is kept there — a line that named no counter at all is skipped rather than
counted as four zeroes — but it does not survive into a total, and a document that sometimes
omitted two of five fields would make every reader write the `?? 0` the writer was avoiding.)
**Nothing nested is added to them**: not `output_tokens_details.thinking_tokens`, not
`usage.iterations[]`, not `cache_creation.ephemeral_5m/1h`, not Codex's
`reasoning_output_tokens` — every one of those is already inside a counter above, and adding
it is how a total silently doubles.
**A counter that is not a non-negative integer is not read as one.** `1.9` is not truncated
to `1` and `"7"` is not parsed to `7`: the line is counted as malformed and skipped, and the
rest of the file is read as usual. A source that changed the shape of a field has stopped
saying what it used to say, and a reader that guesses at the new meaning produces a number
that is wrong without looking wrong. `null` is not a changed shape — it is a counter the
source did not report, and it lands as `0` in the bucket like any other absence.
`requests` is a count of *records that carried usage*, not of your prompts: one turn can be
several assistant messages, and a subagent's messages are its own. It is there so a reader can
say "14 responses" instead of implying a session count it does not have.
### `raw`: the same four counters with no dedupe at all
Beside the five, an optional object with **four** of them summed **per line** rather than per
message:
```json
"raw": { "input": 196, "output": 15702, "cache_create": 307201, "cache_read": 68566141 }
```
That is the number Claude Code's own `/usage` shows, and the store keeps it because two
programs answering one question with two numbers is a thing a user can see. The dedupe rule
below is why they differ: Claude Code writes a message once per content block and every copy
carries the whole `usage` object, so adding the lines up counts a message once per block. On
the maintainer's machine, over the six days both numbers could be measured, `raw` came to
**1.667×** the deduplicated total — and the per-day figures matched `~/.claude/stats-cache.json`
**digit for digit** on all five overlapping days.
**No `requests` in it.** A request is a message and not a line, so the count beside either
reading is the deduplicated one. A per-line count would be a count of content blocks under a
label that promises replies.
**An absent `raw` is a statement, not a hole: the per-line sum of that bucket is its five
counters.** Three kinds of bucket are absent and each is honest about it:
- **Every Codex bucket.** Codex writes each event once; there are no copies to collapse, so
the two numbers are one number and writing it twice would only invite them to drift.
- **Every bucket written before this field existed.** Those lines are behind a cursor that has
already moved and nothing will read them again, so the per-line sum is not recoverable —
and multiplying by 1.667 would be inventing a number, which rule 2 of
[`limits-contract.md`](limits-contract.md) has forbidden since before this file existed.
- **Any bucket a future reader writes without one**, for whatever reason it has.
**It is written the moment anything credits a per-line sum to the bucket, seeded from the five
counters**, so the sentence above keeps being true rather than becoming a bucket whose `raw` is
smaller than its dedupe. `raw ≥ the five counters` holds hour by hour and model by model, and
it holds because the per-line sum is accumulated against the **same dedupe key** as the
deduplicated one — never per bucket, which a rewrite that swapped one message for another of
the same size could have pushed below its own dedupe.
**The `version` did not change.** `raw` is an added optional field, unknown keys survive a
rewrite, and every reader that has ever existed keeps working: an older build reading a newer
document walks past it, and a newer build reading an older document reads the absence as the
sentence above.
### Model ids are stored as reported, never canonicalised
`claude-opus-5` and the bare `opus` both appear in real transcripts, sometimes in the same
file, and they are the same model. The store **does not merge them**, because merging means a
table of aliases that has to be right about a name nobody here controls, and a wrong merge is
unrecoverable once it has been written. A reader that wants to group them may; it will be
grouping something it can still see, which is the difference.
Ids are never translated either. `claude-opus-5` is a name, not a string to localise, and the
six locale files have nothing to say about it.
**The one id this file writes itself is `unknown`**, for records whose source never named a
model — a Codex `token_count` event before the first `turn_context` in its log. Those tokens
were spent and are counted; which model spent them is a thing nobody here knows, and saying so
is cheaper than attributing them to the model that happened to come next.
### Why these key names
`limits.json` renames everything it reads into camelCase, because it is a contract with
another program and it has one spelling for an idea two sources spell differently. This file
does the opposite on purpose: its counters keep **the shape the usage fields already have at
both sources**, so a reader comparing this document with a raw `message.usage` block does not
have to hold a rename in their head. The keys that are about the document rather than about
tokens — `version`, `month`, `since`, `scanned_at`, `providers`, `buckets` — are this file's
own, and there is exactly one of them (`scanned_at`) whose `limits.json` cousin is spelled the
other way.
If that trade ever stops being worth it, it is a `version` bump and a two-line mapping, not a
migration: nothing outside this repository reads the file.
## No local time, anywhere in this file
Every instant here is UTC, and every bucket key is a **UTC hour**. There is no offset field,
no time-zone name, and no local date — and there will not be one without a decision in front
of it.
This is enforced rather than promised:
`crates/nazar-core/tests/hygiene.rs::nothing_in_the_workspace_asks_the_machine_what_time_zone_it_is_in`
greps every `.rs` file in the workspace for the names a local-time conversion would have to
use and fails the build on a hit. The reasoning is the one at the end of
[`pinned-internal-formats.md`](pinned-internal-formats.md): the standard library has no
time-zone database, so a local offset in the Rust core costs either a dependency or
hand-written daylight-saving code in the crate that is meant to be boring, while the panel is
JavaScript, where a local date is one call.
**So the grain is an hour, not a day, and that is the whole reason.** A UTC *day* cannot be
split into local days at +03:00 — the boundary falls inside it, and no arithmetic afterwards
can put it back. UTC *hours* can: a reader at any whole-hour offset re-buckets them into local
days exactly, by shifting the key.
**The panel derives, the store stores.** Local days, and **weeks that start on Monday in local
time**, are computed by the reader from these hours, at read time, every time — the same rule
`limits-contract.md` already applies to countdowns and binding windows: the file holds what was
measured, the display holds what changes with the clock.
**The one honest limit:** an offset that is not a whole number of hours — `+05:30`, `+05:45` —
has a local day boundary inside a bucket. Such a bucket is attributed to the local day it
*starts* in, so at most one hour of a day's tokens can land on the neighbouring day. The
alternative is splitting a bucket by a ratio, which invents numbers, and rule 2 of
`limits-contract.md` is older than this file.
## The headline number
**`input + output + cache_read + cache_create` — all four.** That is the number the usage view
shows large, the number the tray tooltip carries, the number a calendar cell is shaded from,
and the number "this week" means without a qualifier. It is **the same definition Claude
Code's own `/usage` calls *total tokens***, and that is the whole of the argument: a user who
has both windows open is comparing two numbers, and two numbers that answer one question have
to be one number.
**The four parts are shown beside it, always.** Under the headline and under every model row,
in the view itself rather than in a footnote:
```text
In 802K · Out 354K · Cache read 1B · Cache write 253K
```
An absent counter prints an em dash there rather than dropping out of the line, because a
breakdown that does not add up to the number above it is worse than one with a hole named in
it.
### Why the breakdown exists
Because the headline is overwhelmingly cache. Measured on the maintainer's machine over six
days of real work, deduplicated:
| | tokens | share |
|---|---|---|
| `cache_read` | 1 491 769 695 | **98.5 %** |
| `cache_create` | 20 109 628 | 1.3 % |
| `output` | 2 145 844 | 0.14 % |
| `input` | 43 724 | 0.003 % |
A total is 98.5 % cache reads, so a number on its own is a number about cache behaviour with
the work lost inside the rounding: `input + output + cache_create` — the part that was actually
produced or newly processed — is 22.3 M of that 1.5 B, and it is the one that moves when a day
was busy. **That measurement is why the four parts are on screen.** It is no longer why the
headline is smaller than `/usage`'s, which is what it used to argue for and what T-WP16 wrote
down here: the arithmetic was right and the reader was not served by it. Side by side the two
windows said 1.4 M and 1.0 B, and a tray answering `/usage`'s question with 0.1 % of `/usage`'s
answer does not read as careful — it reads as broken. So the total is the total, and the reader
can see for themselves, on the same screen, that the billion is the cache.
Everything is in the file either way. The store has never held a headline: it holds four
counters per model per hour, and which sum is drawn large is a decision the view makes, not a
fact the document records.
**Both surfaces use this definition.** `nazar_tray::usage::headline` — the tray tooltip's *This
week* line, and the model it names as the busiest — is the same four-way sum as the panel's, so
the tooltip and the view cannot disagree about a number the user can check against a third
window. Between T-WP20 and T-WP20b they briefly did, which is written up in `PROJECT.md` §9.
## `claude_reported`: days this store never measured
A third key under `providers`, and **not a reader**. It is a copy of what Claude Code's own
`~/.claude/stats-cache.json` says about the days **older than the transcripts** — days this
store has no way to count, because Claude Code has already pruned the files they were in.
```json
"claude_reported": {
"source": "claude-stats-cache",
"buckets": {
"2026-09-07T00": {
"claude-opus-5": { "input": 0, "output": 0, "cache_create": 0, "cache_read": 0,
"requests": 0, "reported_total": 2078342191 }
}
}
}
```
| Field | Meaning |
|---|---|
| `source` | `claude-stats-cache`, on the provider block rather than on every row. The key already says where the numbers came from; this says it in words, in the one place somebody opening the file will look. |
| bucket key | `YYYY-MM-DDT00` — **the date that program computed, at hour zero of itself**. It is a day, not an hour: nothing happened at midnight in particular, and the hour is there because this file's keys are hours. |
| `reported_total` | The one number the source holds for that model on that day. **Per-line**, like everything in that file. |
| the five counters | `0`. `stats-cache.json` holds one total per model per day and no split, and writing a guess at the split would be inventing four numbers out of one. |
Seven rules, and each of them is what keeps this from becoming a second, quieter source of
truth:
1. **It is off by default.** `usage.fillHistoryFromStats` in `config.json`, and a history that
quietly mixes two kinds of number is worse than a shorter one.
2. **It is never merged into `claude`** and never added to any counter of it. A reader summing
the providers gets what this machine measured; this block is lifted out before the panel
sees the providers at all.
3. **Only days strictly before the transcripts.** The boundary is the earliest hour the
`claude` provider holds, floored to its UTC day — *not* the store-wide `since`, which on a
machine with Codex on it is dragged back by rollout logs that say nothing about Claude
Code's transcripts. On the maintainer's machine the two differ by twelve days.
4. **It does not move the boundary it measures itself against.** `since` is computed from the
measured providers alone; counting a backfilled day in it would move the boundary back
behind itself and leave the store oscillating between two answers.
5. **It is a copy, not an accumulation.** Every run rewrites the whole block from the file, so
running it twice writes the same bytes and a day the file no longer holds stops being in
the store. There is no journal, no generation and no `applied_through`: there is nothing to
be idempotent *about*.
6. **The dates are that program's, and they are carried as dates.** They were measured against
UTC days and matched digit for digit. A reported day is handed to the panel as
`YYYY-MM-DD`, never re-bucketed through a time zone, so nothing between the file and the
screen converts an offset and nothing can be an hour wrong about a number that never had an
hour. The one boundary case a negative offset can produce — a local day that is partly
covered by transcripts and also carries a reported number — is dropped by the panel, which
is the side that knows the offset.
7. **The panel draws it apart.** Outlined rather than shaded on the calendar, outlined in the
weeks list, and *reported by Claude Code* in words wherever it appears — because a shade is
a rank against the days beside it, and these are counted in a different unit.
**Why a copy of another program's arithmetic is in this file at all.** Because the alternative
was a history that begins the day this product was installed, on a machine whose transcripts
reach back six days and whose statistics cache reaches back twenty-two. The numbers are real;
what they are not is *ours*, and every one of the seven rules above exists to keep that
distinction visible rather than to hide it.
## The two counts, and which one the panel draws
`docs/pinned-internal-formats.md` pins the fields; this is the decision on top of them.
**The default is the deduplicated spend.** It is the number that answers *what did this machine
use*, and it is the only one here that is a measurement of that.
**`usage.countLikeClaudeCode` swaps every counter for its per-line twin.** `get_usage` then
answers with `mode: "per_line"` and the same bucket shape, so the panel's arithmetic, its
calendar, its weeks and its details are one set of functions over one shape rather than two
copies with two chances to disagree. The headline carries a small tag saying so, and the tray
tooltip follows the same setting — the two surfaces of this application cannot answer one
question with two numbers, which is what `PROJECT.md` §9 spent a package on.
The reason a user would want it: `/usage` shows the per-line number, and somebody comparing the
two windows deserves to be able to make them agree rather than being told one of them is wrong.
The reason it is not the default:
[anthropics/claude-code#91775](https://github.com/anthropics/claude-code/issues/91775#issuecomment-5654151098)
— the per-line sum counts one message once per content block, which is 1.667× the real spend
here and is not a constant that could be divided back out.
## No cost, in v1
There is **no cost field**, and this is not an omission to be fixed by adding one quietly.
A subscription's list price is not a bill. Multiplying these counters by a published rate
produces a dollar figure that is not what anyone paid, that has to be footnoted on every row,
and that is wrong the week a price changes — while a price table is exactly the kind of upkeep
that goes stale first and loudly. Claude Code's status-line payload does carry a real
cumulative `cost.total_cost_usd`, and it is still a local estimate from list prices rather than
an invoice; Codex publishes nothing comparable at all.
If cost is ever added it is a `pricing.json` **data file**, a `version` bump here, an explicit
"estimated from list prices, not your bill" beside every figure, and a model that is not in the
table showing **no cost** rather than a guess. That is T-WP19, and it is deliberately after v1.
## How a scan adds to the store, and why a bucket never goes down
A scan does not recompute the store and does not rescan the logs. It reads **the bytes
nobody has read yet** — each log is followed by a cursor, `(file identity, byte offset)` —
turns them into hourly buckets, and **adds** those to the months they belong to. Nothing
already in a month is recomputed or compared away; a counter only ever grows, and it grows
by exactly what this pass read.
That is the point of the file rather than a detail of it. Claude Code prunes transcripts (30
days by default; six days of history survived on the maintainer's machine) and Codex sessions
are the user's to delete or archive. A store that recomputed from the logs would answer "all
time" with "the last few days" and would answer differently every week. A store that adds
keeps what the logs no longer hold, and a pruned transcript costs nothing that was already
counted.
Three consequences worth naming:
- **A second scan over unchanged logs adds nothing at all** — not because the numbers agree
but because there are no new bytes to read. Every offset is already past the end, no month
document differs from what is on disk, and nothing is rewritten: the directory is left byte
for byte as it was.
- **And reading the same bytes *again* also adds nothing.** A log that was truncated, rotated
or rewritten is read from the top, which is the one moment the offset stops being a
guarantee. What stands in for it is the cursor's record of everything that log has already
been credited for: a message it holds credits only what it has grown by, and an event it
holds credits nothing. So the totals are the same after a prune as before one — which is
what the word idempotent above is worth, and what the first version got wrong past the last
sixteen messages of a file.
- **`raw` is idempotent too, and it needed a second number to be.** The deduplicated side can
say *the largest reading, minus what is already credited* in either direction, because every
copy of a message carries the whole `usage` object. The per-line side is a **sum over
lines**, and the same line read twice is two lines unless something remembers that it is
not. So each dedupe key carries two per-line figures rather than one: the **high-water
mark**, which is what the bucket has already been credited, and **what the file holds now**,
which a restart forgets and an append adds to. What is credited is the difference, saturating
at nothing, and the invariant is:
> **A bucket's `raw` counters are the largest per-line sum the transcripts behind them have
> ever held, and reading any byte a second time adds nothing to them.**
The sequence a byte offset alone gets wrong: a transcript is pruned to its first line and a
copy of the original is then put back. The restart credits nothing, and the pass after it is
an **ordinary append** — valid offset, matching fingerprint, nothing to say the file ever
shrank — which would credit lines two and three a second time, permanently. With *what the
file holds now* it climbs back to where the high-water mark already is, and credits nothing.
- **Largest-wins is a rule about copies of one message, and it lives before the buckets.**
Claude Code writes a message once per content block and every copy carries the whole
`usage` object; the scan keeps the copy with the largest total, credits that one, and a
bucket never sees the others. What is remembered for the key is the **largest** reading
ever seen, counter by counter, and never the latest: three passes seeing 100, then 90, then
100 credit 100 once, where remembering 90 would have let the third pass add another 10.
Codex writes each event once and gives it no id, so there is nothing to deduplicate there —
and nothing to deduplicate *with*; see the Codex section below for what stands in its place.
- **Numbers are never revised, including a wrong one.** There is no pass that could revise
them: the scan that would have to notice is the one that already moved its cursor past
those bytes. If a scan ever over-counts, the fix is a **rebuild** — the cursors and the
months deleted together, and whatever the logs still hold counted again from the top.
Whatever they no longer hold is gone, which is why a rebuild is a decision somebody makes
rather than something a reader does to recover.
## The cursor documents, and the one way to double count
Beside the month documents, in the same directory: `cursors-claude.json` and
`cursors-codex.json` — **one per reader**. They are not part of this contract. They are the
writer's own bookkeeping, nothing reads them but the scan that wrote them, and their shape
may change in any release without a `version` bump here.
What is worth writing down is what they are *for*, because deleting one has a consequence
nobody would guess:
- **They hold where each log was read up to**, filed under a hash of its path rather than the
path — `~/.claude/projects/` is named after every working directory somebody has opened a
session in, and none of that belongs in a file this product writes. A credited message is
filed under a hash of its identifiers, and a rollout's events under a hash of their
timestamps and counters. Nothing in these documents names anything on the machine.
- **"Where it was read up to" is three things, not two.** The file's identity says *this is
the same file*; the byte offset says *this is how far*; and a hash of the 64 bytes
immediately before that offset says *and this is still the same place*. The third exists
because the first two can both be satisfied by a file that was rewritten in place — same
birth time, same first 512 bytes, same length — inside which the old offset now points at
different content, and everything written before it would never be read. A mismatch is
treated exactly like a truncation: read the file again from the top.
- **They hold what has already been credited, per log, in full.** For a transcript that is
every `(message.id, requestId)` key it has produced, as a bare array of **nine** numbers per
key — the hash, the largest reading of the message, and that message's per-line sum — or
thirteen for a key whose file has been truncated below its high-water mark, where the last
four say what the file holds now. (A row of **five** is one written before `raw` existed:
its per-line half is seeded from its deduplicated half, which is a lower bound and is
exactly what an absent `raw` on a bucket already means, so the two cancel and the first
restart after an upgrade credits the file's true per-line sum once.) For a rollout, the
fingerprint of every event. That is
what makes reading a log from the top again *safe* rather than a doubling: a re-read record
credits the difference between the largest copy now and the most already credited, which
for the same bytes is nothing. It is also the size of these documents — a few hundred
kilobytes on a machine with 9 500 messages of history — and the trade is deliberate: a
bounded window of recent keys is smaller and only protects the last few messages of each
file, which is not where a truncation starts.
- **They are also the write-ahead half of the counting invariant.** A pass writes its new
offsets *and* the totals read from them in one atomic write, as a `pending` journal, before
those totals reach any month; then each provider's block in a month document stamps the
scan `generation` it last absorbed in `applied_through`, so replaying a pending entry is a
no-operation for a month that already carries it. A crash before that write loses a pass
that is simply repeated; a crash after it leaves entries the next pass files. There is no
state in which an offset moved past bytes whose totals were never recorded, and none in
which totals were recorded twice.
- **The journal is per month, and an entry that cannot be filed stays in it.** A month whose
document no longer parses is skipped, and its entry waits — through any number of scans —
until the month can be written. See the damaged-month section below for what that is worth.
- **A cursor document that no longer parses is an error, not a fresh start.** Treating it as
absent would reset every offset to zero and count every surviving log into months that
already hold it. The scan stops and says so instead.
- **Deleting one by hand double counts, and nothing can detect it.** An absent cursor is
indistinguishable from a machine that has never scanned — which is what makes the only
supported reset `store::rebuild()`: the cursors **and** the month documents, removed
together. Removing the months alone leaves a store that will never read those logs again;
removing the cursors alone adds every surviving log to months that already hold it. Either
half on its own is the bug; both together is the escape hatch.
## The two providers, and what Codex spells differently
`claude` and `codex` file into the same month documents, under their own key, with their own
`applied_through` stamp and their own cursor document. Neither reader can disturb the other's
offsets, and a month may hold one, both, or neither.
`claude_reported` has no cursor and no journal at all, because it is not a reader: its whole
block is rewritten from `stats-cache.json` on every pass that runs it. Deleting it by hand
costs nothing; the next pass puts it back.
The Claude side is `message.usage` as the server reported it, deduplicated by
`(message.id, requestId)` — minus two kinds of line that carry a full set of counters and are
not billed usage: a message whose model is ``, and a line marked
**`isApiErrorMessage`**, which is what an API failure looks like in a transcript. (On the
maintainer's machine all 11 of those were also ``, so reading the flag changed no
total there; it is read because the flag, not the model name, is what promises the line is an
error, and a billed model name marked as an error would otherwise be counted.)
The Codex side reads `payload.info.last_token_usage` on every `token_count` event and differs
in four ways that are visible in the file:
- **`input` has the cache taken out of it.** Codex's `input_tokens` **includes** the cached
part, so the store writes `input_tokens − cached_input_tokens` as `input` and
`cached_input_tokens` as `cache_read`. Adding the two as reported would count the cache
twice. (Claude Code reports them as separate numbers already, which is why only this side
subtracts.)
- **`cache_create` is `0`.** It is `cache_write_input_tokens` when the event carries one —
the field is read rather than assumed, so the day Codex starts reporting cache writes the
store carries them — and it was `0` on every event observed on the maintainer's machine, all
446 the reader counted and the 438 in the archived tree beside them.
A missing one is read as `0` rather than as unknown: a turn that wrote no cache wrote none.
- **The model comes from another line.** A `token_count` event does not name the model that
produced it; the session's `turn_context` lines do, and the store attributes an event to the
last model named before it. An event that arrives before any `turn_context` is filed under
**`unknown`** — the one id this file writes itself, and the reason it exists.
- **The cumulative counter is never read.** `info.total_token_usage` looks like a session
total and is not: it falls back down mid-session when the context is compacted, in 3 of the
22 logs under `sessions/` here, and summing the per-turn counter disagreed with it in 8 of
30 files, once by a factor of 43. Nor are `reasoning_output_tokens`, which is already inside
`output_tokens`, and `total_tokens`, which is the sum of two counters that are already here.
**Counting an event once, without an event id.** Claude Code's `(message.id, requestId)` has
no counterpart in a rollout log, so *which bytes have been read* is the whole answer, and it
is exact as long as a log is only appended to. Two shapes get past it, and each has a rule.
The first is a log that is **truncated or rewritten**, after which the reader goes back to
byte zero and reads events it has already counted. So the cursor carries a fingerprint of
every event it has credited — the event's timestamp to the millisecond and its four raw
counters, hashed — and a pass that had to restart matches what it reads against that set
before crediting anything, consuming each match, so a log that genuinely holds two identical
events keeps both. It is the same thing the transcript reader does with its dedupe keys, built
out of the only evidence a rollout offers.
The second is a fork or a resume that copies a run of events into a **new** log, which arrives
as a new path with a cursor that has read nothing. The rule, stated so it can be argued with:
> A log whose **opening run** of events is, event for event — same timestamp to the
> millisecond, same four counters — the opening run of a log already known is a copy of it up
> to the point where the two diverge. That run is skipped; whichever of the two was read first
> keeps the tokens, and it does not matter which, because exactly one copy is counted.
Only a leading run, and only against another log's leading run: the same event in the middle
of two sessions is a coincidence worth nothing, while the same event *first in both files* is
not a coincidence at all. The guard is bounded — only the first 32 events of each log are
compared, because the rule asks every log about every other log — so a copy longer than that
is caught for its first 32 events and counted again for the rest. No fork on the maintainer's
machine copied any events at all; the rule is there because a byte offset alone would have no
answer if one did.
**`archived_sessions/` is not read.** Codex keeps logs of exactly this shape in a second tree,
and this store walks only `sessions/`. The reason is mechanical rather than squeamish: a cursor
is filed under a hash of the path, so a log that Codex *moves* into `archived_sessions/`
arrives as a file nothing has read, and every event in it would be counted a second time.
Reading one tree and not the other is what makes archiving a session leave the totals exactly
as they were. The cost, stated rather than discovered: **a session archived before it was ever
scanned is never counted at all.** Changing that means reading both trees and telling them
apart by something other than a path, and it is not a line this file can add on its own.
**A compressed rollout is read.** Codex has shipped a worker since 0.153.4 that rewrites every
rollout whose mtime is more than seven days old as `.jsonl.zst` and deletes the plain
file; it sits behind the `local_thread_store_compression` flag, measured `under development`
and `false` on 2026-09-15 under 0.154.0. T-WP25 gave this pass the name and a count, because a
week of history disappearing with nothing anywhere to say so is the one failure this document
exists to prevent. T-WP26 gave it a decoder, so those events are now in the totals like any
others. `UsageSummary::files_compressed`, summed into the bridge's
`UsageScan.files_compressed`, is how many of the logs a pass read were archives — still `0` on
every machine whose Codex has the flag off. The format is in
[`pinned-internal-formats.md`](pinned-internal-formats.md) under "Compression".
**Two things follow from compression being a rename**, and both are load bearing:
* **A rollout's cursor is filed under its plain name.** `.jsonl` and `.jsonl.zst`
are one session in the two states Codex keeps it in, so keying the cursor on the name on
disk would meet the archive as a log nothing had ever read and credit the whole session a
second time. Keyed on the plain name, the sweep is a file that was replaced: the cursor's
identity check notices, the pass restarts, and the events already credited are matched off
one by one. Measured, in the test named after it: a forty-event log swept without this
credits eight of them twice — the eight past the fork rule's thirty-two-event reach.
* **Only one of the two names is walked.** During the sweep, and after Codex reopens an
archived thread, both exist for a moment. The plain one is the log; the archive beside it is
passed over and not counted.
**An archive that will not decode is a file that failed to open**, `files_unreadable`, with its
cursor kept exactly as it was — the same treatment a locked transcript gets, for the same
reason: damage today may be readable tomorrow, and a pass that credited half a log and wrote a
finished cursor could never find out.
## A damaged month is left alone
A file that does not parse is **reported and kept**, never replaced by an empty one and never
"repaired". The months beside it still load; the damaged one displays as absent, with the error
visible rather than swallowed.
That is the opposite of `alerts.json`'s rule, and for the opposite reason: a lost alert record
costs one extra toast, while a lost month costs a month that may no longer exist anywhere else.
Deleting it is a thing the user does, once they know.
**What it costs in the pass that finds it: nothing, and that is the fix.** The totals that
scan had just read for that month stay in the journal inside the cursor document, one entry
per month, and every later scan tries again. The moment the month becomes readable — the user
deleted the broken file, or put back a copy of it — the entry is filed, once, guarded by the
same `applied_through` stamp as everything else. Every month beside it keeps its own totals
and files them immediately.
The first version dropped those totals instead, which was a quiet way of losing them for good:
the bytes they came from are behind a cursor that has already moved, so nothing would ever
read them again, and repairing the month afterwards could not bring them back. A journal that
waits is the whole of the difference.
Two honest limits. A month that stays damaged keeps one merged entry, carrying the newest
generation, so the journal does not grow with every scan — and a user who "repairs" the month
by restoring an **older** copy of it, one whose `applied_through` is behind some of the
generations merged into that entry, gets those generations counted twice. Nothing can tell
that document apart from the one that was damaged. A rebuild is still the supported answer,
and it brings a month back only as far as the logs still reach.
## One writer
The scan runs inside the tray process, on the thread that already owns every reader, and it
writes under the advisory lock in `~/.nazar/limits.lock` that already makes "one writer" true
for `limits.json` — no second lock, no second discipline. A tray whose lock was reclaimed while
its machine slept stops writing this file at the same moment it stops writing that one.
**The scan is not on the refresh path.** Quota is the reason this application exists and it
reads two small files in milliseconds; a usage scan reads hundreds of megabytes. It runs once
at start-up, when the usage view is opened, and at most once every five minutes — and a quota
reading never waits behind one.
## What this file never contains
No prompt text. No response text. No reasoning. No tool input or output. No file path, project
name, working directory, git branch, session id, message id, request id, or account
identifier. Nothing that is a name of anything on this machine.
What is in it is what the table above lists: five integers, four more under `raw`, a model id,
a UTC hour. The reader that produces it builds those values into a new object instead of
filtering a parsed line, and a leak test feeds it records whose every text field is a sentinel
and fails if the sentinel turns up in the store or in anything the store serialises. The
statistics reader is held to the same rule and the same test: it has a field for three keys of
`stats-cache.json` and for nothing else, so `longestSession.sessionId` — an identifier of a
session on this machine — is walked past by the deserialiser rather than filtered out by hand. The full inventory of what is read
and what is not is [`pinned-internal-formats.md`](pinned-internal-formats.md).
## `limits.json` does not change
**Nothing on this page touches `limits.json`.** It stays frozen at `schemaVersion: 1`, it keeps
the same fields, and Nazar's quota strip needs no change on account of any of this — which is
the reason this is a new file rather than a field added to that one. The contract there is
explicit that `cost.*` and `context_window.*` have no field and are "not growing one", and this
is what growing somewhere else looks like.
`~/.nazar/limits/.json` is still reserved for v2 multi-account support, and this file
would gain the same split in the same release if it ever does.
## What changing this costs
Today, one repository: this one.
1. Update the writer and its tests in `crates/nazar-core/src/usage/`.
2. Update this document, in the same commit.
3. Bump `version` only for a change that removes or repurposes a field. `raw`,
`reported_total` and the `claude_reported` provider were all **added**, so `version` is
still `1`: unknown keys survive a rewrite, an older build walks past them, and a newer
build reads their absence as the sentence each of them defines.
4. The day a second program reads this file, add the step that copies a sample into it — and
that day, this page stops being a plan and becomes a promise.