# 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.