Files
lumbridge-code/docs/decisions/0013-first-usage-adapter-codex-app-server.md
Metal AgentandClaude Opus 5 ef52aa7ce2 Replace the footer's placeholder usage with a real observation ledger
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>
2026-08-31 21:47:11 -07:00

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, or LocallyMeasured when 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 -32601 regardless 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 -32600 for an API-key user. That records an unavailable observation for both windows. Any other rejection faults the probe.
  • Two windows are two profiles. primary and secondary get 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 resetsAt is 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. HarnessError is Copy and holds only static discriminators, an io::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 Drop calls 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.