The footer showed invented percentages. It now shows what two harnesses actually report, or says it does not know. lumbridge-core gains an append-only per-profile UsageLedger and a projection that labels every derived value estimated, withholds a burn rate from a single sample, withholds a window fraction with no reported ceiling, withholds an exhaustion estimate that lands after the reset, and reports an expired window as rolled over rather than freezing its last percentage. A missing fact renders as missing, never as zero. (0012) lumbridge-harness is the impure side: processes, clocks, and untrusted wire text in, observations out. Three adapters: - Codex's account/rateLimits/read over the app-server's JSON-RPC stdio. The client cannot express a request outside a two-variant enum and answers every server-to-client request with -32601, so a harness asking Lumbridge for a credential is refused by construction. (0013) - Claude Code's session transcripts, as a byte-offset tail follower that reports nothing until the backlog is read to EOF — a partially-read backlog is indistinguishable from a burst of spend, and the first run against 20 MB reported forty-six billion tokens an hour. The parser models four counters, so the conversations in those files are not representable. (0014) - Claude Code's five-hour and seven-day subscription windows, via a bridge installed as its statusLine command. 0014 had claimed no such surface existed; it does, and the record is corrected in place rather than quietly edited. Lumbridge does not read the OAuth credential to call the account usage endpoint, which is what comparable tools do — AGENTS.md forbids it, and 0015 says so rather than leaving the gap unexplained. Also in here: a capability-check ordering fix in the workspace reducer, where the applied-request replay table was consulted before the capability check and so answered questions the caller had no right to ask; the GPUI spike wired to the live probes with per-harness gauges and provenance chips; and a launcher that matches its own window by PID, because GPUI sets WM_NAME but not _NET_WM_NAME and a title match never succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.6 KiB
0013: The first usage adapter is the Codex app-server rate-limit probe
Status: accepted; wire contract verified against one live account.
Decision 0012 built a usage ledger and a footer that refuse to invent numbers, but every observation reaching them was a declared fixture. This decision picks the first real source and records the rules that choice establishes for the adapters after it.
Why Codex first
Of the seven captured harnesses, Codex is the only one whose quota surface is
all four of: prose-documented upstream with a worked example
(codex-rs/app-server/README.md, "7) Rate limits (ChatGPT)"), pinned by a
checked-in JSON Schema, genuinely provider-originated, and readable without
Lumbridge touching a credential. account/rateLimits/read returns
usedPercent, windowDurationMins, and resetsAt; the app-server parses
those out of the backend's x-codex-*-used-percent response headers.
Lumbridge launches codex app-server and lets the harness resolve its own
authentication from CODEX_HOME. That satisfies the product spec's
"observes only documented status, usage, and protocol surfaces" without any of
the credential handling AGENTS.md forbids. Upstream is Apache-2.0; only the
observable wire contract is mirrored, and its source, commit, and license are
recorded in the parser and the tests.
The provenance test for every future adapter
Ask whether the harness computes the value or forwards it.
- Forwards a provider's own number:
ProviderReported. - Computes, counts, or estimates it:
HarnessReported, orLocallyMeasuredwhen Lumbridge did the counting.
Codex forwards, so its windows are provider-reported. The ACP usage_update
notification, by contrast, is a context window gauge, and at least one
captured harness fills it with an explicit local estimate. It must never be fed
through the same path: a context window is not a quota, and rendering one as
"62% used" would imply a subscription limit that does not exist.
Rules this adapter establishes
- Refusal is structural, not procedural. The app-server can send its client
account/chatgptAuthTokens/refresh, which returns a bare access token. The client has no free-form method string: outbound requests come from a two-variant enum, and every server-to-client request is answered-32601regardless of what it asks for. Asking for a credential is unrepresentable rather than merely declined, and refusals are counted so the path is auditable. - An account with no quota is a fact, not a fault. Codex answers
-32600for an API-key user. That records an unavailable observation for both windows. Any other rejection faults the probe. - Two windows are two profiles.
primaryandsecondaryget separate, account-free profile identifiers. Merging them would mean silently choosing one. A profile ID is persisted, so it never carries an account identity. - A sparse push neither clears nor merges. Upstream documents that nullable values absent from a rolling update "do not clear a previously observed value". An absent window on a notification therefore records nothing. It is also not merged into the last full read: a merged composite would present itself as provider-reported while being partly a memory.
- Clock disagreement costs the window, not the number. A window whose
computed start is after the observation is dropped while the provider's
percentage is kept. A reading taken at or after
resetsAtis unusable entirely, because 0012 requires an ended window to read as rolled over. - Identical readings are suppressed, with a floor. The ledger retains a bounded history per profile; re-recording an unchanged percentage every poll would evict real history and destroy the burn baseline. An unchanged reading is re-emitted only after five minutes, which keeps a rate derivable without flooding.
- Errors cannot carry text.
HarnessErrorisCopyand holds only static discriminators, anio::ErrorKind, and numbers. A spawn failure deliberately discards the underlying error so a filesystem path cannot travel into a log, and a rejection keeps the JSON-RPC code while discarding the backend message. - The child is contained. Cleared environment with a literal allowlist,
pipes rather than a PTY, stderr discarded, and a per-line byte cap. The child
leads its own process group and the group is what gets killed, because a pipe
reports end of file only when every write end closes: a launcher that execs
the real program as a grandchild with inherited stdio — which is how the npm
distribution of Codex works — would otherwise leave a reader blocked on a pipe
nothing will ever write to. Shutdown signals, kills the group, and joins only
the worker. The reader is never joined, so a descendant that escapes the group
can leak a parked thread but cannot hang the caller, which matters because
Dropcalls shutdown.
Verified against a live account
One reading was taken with the user's consent on 2026-08-31, against
codex-cli 0.145.0 installed as a standalone native binary. It confirmed the
whole contract: usedPercent an integer, resetsAt Unix seconds, secondary
null, and no unmodelled fields inside the window object. The result body also
carried rateLimitResetCredits, rateLimitsByLimitId, planType, limitId,
limitName, individualLimit, credits, and spendControlReached, all
correctly ignored by the parser. Two unsolicited notifications arrived during
the exchange — configWarning and remoteControl/status/changed — which
exercised the ignore path with real traffic. No server-to-client request
arrived, so the refusal path remains proven only by test.
The live account's windowDurationMins was 10080: a weekly window, not the
fifteen-minute example in the upstream README. That exposed a real defect no
fixture built from the documented example would have caught —
format_duration_ms rendered it as "151h 30m" — and days were added to the
formatter as a result. Fixtures agree with documentation; only a real account
disagrees with your assumptions.
What is still not true
The whole test suite runs without the codex binary, a ChatGPT account, or
network access, and that property must be preserved: CI must never depend on an
installed harness. A single live reading verifies the shape of one account on
one plan; it does not exercise the secondary window, the -32600 no-quota
path, or a window that rolls mid-read. Those remain covered by synthetic
transcripts only.
Wiring the probe into the running shell means spawning a process against the user's own subscription account on every launch. Until that is wired, the shell keeps rendering its declared fixture.