ADR-166: Single source of truth for model context-window resolution¶
Context¶
The context window is the denominator of nearly everything the toolchain reports
or decides. ways context divides by it to render the usage gauge. ADR-126 makes
way refire window-relative: a way's half-life is expressed as a fraction of the
window, so a wrong window rescales the entire disclosure curve. sensor-peers
divides by it to show peer session pressure.
Three separate implementations answered that one question, and they disagreed:
| Site | Rule |
|---|---|
ways-cli/src/cmd/context.rs:239 (model_to_window) |
opus-4 → 1M; sonnet|haiku → 200K; else env override, else 200K |
ways-cli/src/session.rs:322 (context_window_from_transcript) |
opus-4 → 1M; else env override, else 200K |
sensor-peers/src/lib.rs:567 |
[1m]|opus-4|sonnet-4 → 1M; - → 0; else 200K |
A fourth site, ways-cli/src/cmd/show/mod.rs:88, hardcodes .unwrap_or(200_000)
on the failure path.
Each is a substring allowlist written against the model lineup of its day, and each has since gone stale. The lineup they encode no longer matches the models in use:
claude-fable-5matches no branch in any of them and falls to the 200K default. It has a 1M window. A session observed 212,899 tokens with no forced compaction whileways contextreportedtokens_total: 200000andpct_used: 106.claude-sonnet-5has a 1M window;context.rsclassifies it 200K on thesonnetsubstring.sonnet-4resolves to 1M insensor-peersand 200K incontext.rs. Both cannot be right.- Conversely,
opus-4is matched as a bare substring, so anyopus-4*id is called 1M whether or not that is true of the specific model.
Two structural defects compound the staleness:
The [1m] suffix is never present. sensor-peers tests for it, but Claude
Code writes the bare model id to the transcript (claude-opus-4-8, not
claude-opus-4-8[1m]) — confirmed across ~78,000 model records in local
transcript history. That branch has never matched. Detection therefore cannot
observe the harness's window setting at all; it can only observe the model id.
The documented override does not work where it is most needed. In both
context.rs and session.rs, CLAUDE_CONTEXT_WINDOW is read only from the
fallback arm. Any model that matches a substring branch — every Sonnet and Haiku
in context.rs — ignores the override entirely, contradicting
skills/context-status/SKILL.md, which presents it as the general escape hatch.
The failure is silent by construction. A resolved window carries no indication of whether it was detected or defaulted, so a wrong denominator is indistinguishable from a right one at every consumer.
Decision¶
Resolution of the context window becomes a single function in ways-core
(ways_core::context_window), and every site calls it. No consumer computes a
window itself.
Resolution order, applied in this order, first match wins:
CLAUDE_CONTEXT_WINDOW, if set and parseable — unconditionally, ahead of all detection. Detection cannot see the harness's active window (see Context), so the operator override must always outrank the model table, never merely backstop it.- An explicit model table, matched against the full model id rather than by loose substring.
- A conservative 200K default.
The result carries its provenance. The resolver returns the window together
with a WindowSource (EnvOverride / ModelTable / Default), and
ways context --json emits it as window_source. A default is thereby reported
as a default rather than presented as a detection. This is the property that makes
the next stale-table failure observable instead of silent: the Fable session above
would have read window_source: "default" at the moment it was wrong.
The table is explicit and enumerated, not a substring heuristic. Substring
matching is what failed: sonnet swallowing sonnet-5, opus-4 swallowing every
Opus 4.x regardless of window. Unknown models fall to the default and say so,
which is a correctable, visible state — unlike a wrong match, which is not.
Ids are matched as a boundary-delimited component of the model string rather
than anchored at its start, because other harnesses wrap the same id in provider
prefixes and version suffixes (us.anthropic.claude-opus-4-8-v1:0,
claude-opus-4-8@20260115) and this repo supports those deployments. A rule
anchored at byte 0 would regress every Bedrock and Vertex session to the default.
The boundary requirement is what keeps this from degenerating back into substring
matching: claude-sonnet-5 is not found inside claude-sonnet-55, because the
trailing 5 is alphanumeric and therefore a different id, not a qualified form of
this one. Bare family aliases (opus, sonnet) are matched on exact equality
only — an alias is a whole model reference, not a family stem, and prefix-matching
one would resolve claude-sonnet-4-5 to the current Sonnet's window and report it
as a confident detection.
Sentinels are absences, not unknown models. Claude Code writes
"model": "<synthetic>" for interrupt and API-error turns; sensor-peers uses -
as its no-model placeholder. A transcript whose newest assistant turn is an
interrupt still has a real model behind it, so the scanners skip sentinel turns and
keep walking back rather than resolving the sentinel to the default. Nine
transcripts in local history end on a <synthetic> turn; under a naive scan each
would have handed a live 1M session a 200K window.
A peer's window is resolved without the operator's override. sensor-peers
reads other sessions' transcripts, and CLAUDE_CONTEXT_WINDOW states the window
of the process that set it. Applying it to a peer would compute that peer's fill
against the observer's window — an operator with the override at 1M would see a
Haiku peer at 190K/200K, genuinely about to compact, rendered as 19% full. Foreign
sessions therefore resolve through the model table alone
(resolve_for_foreign_session).
The table is a hardcoded enumeration rather than a live Models API lookup
(GET /v1/models/{id} exposes max_input_tokens). The resolver runs in
UserPromptSubmit hooks on every turn; it must be synchronous, offline, and
credential-free. A network call on that path is not acceptable, and a cache of a
network call reintroduces the staleness this ADR exists to remove, with added
failure modes. The table is therefore accepted as a maintenance obligation at
model launch — made tractable by the fact that there is now exactly one of them.
Consequences¶
Positive¶
- One place to update when a model ships. The present bug required four edits in three crates to fix correctly, which is why it was never fixed at all.
- Way refire dynamics (ADR-126) are correctly scaled on every model. On Fable 5 the half-life had been computed against a 200K window inside a 1M one, compressing the disclosure curve by 5x.
- A wrong window becomes visible at the point of use via
window_source. CLAUDE_CONTEXT_WINDOWbehaves as documented, on every model.
Negative¶
- The table must be updated when a model launches or a window changes. This is a real recurring obligation; nothing about the design removes it. It is bounded to one function and covered by tests that pin each known model id.
- An unknown model still resolves to 200K, which will be wrong for any future 1M
model until the table is updated. It is reported as
window_source: "default", making it diagnosable, but a diagnosable wrong answer is still a wrong answer.
Neutral¶
sensor-peerstakes a dependency onways-core. Both are already workspace members, so this is a manifest line, not a structural change.- The
[1m]suffix test insensor-peerswas dead code and is removed. The marker appears in the system prompt text (and so, as prose, inside transcript message content — 414 occurrences locally), but never as amessage.modelvalue: across ~78,000 model records the field is always the bare id. Component matching nonetheless tolerates aclaude-opus-4-8[1m]id, so if Claude Code ever does begin writing the marker, it resolves rather than silently defaulting. - Models absent from the table now resolve to the default where the old
opus-4substring gave them 1M —claude-opus-4-5,claude-opus-4-1,claude-opus-4-0. This is a correction, not a regression: the 1M window arrived with the 4.6 generation, so calling Opus 4.1 a 1M model was exactly the over-broad match this ADR removes. None appear in local transcript history. They can be pinned explicitly once their true windows are confirmed.
Alternatives Considered¶
- Fix the four sites in place, keep them separate. Rejected: it repairs this
instance and preserves the mechanism that produced it. Three resolvers already
drifted into three different answers for
sonnet-4; nothing prevents a fourth divergence at the next model launch. - Resolve from the Models API at runtime. Rejected: the resolver is on the per-turn hook path and must be synchronous, offline, and credential-free. See Decision.
- Fetch the table from the Models API at build time. Rejected for now: it moves the staleness from source to release cadence without removing it, and couples the build to network and credentials. Reconsider if the table proves to churn faster than releases.
- Default unknown models to 1M rather than 200K. Rejected: it is right for
the current lineup but fails unsafely. Over-reporting the window suppresses way
disclosure and under-reports usage — the gauge reads comfortable while the
session is in fact near its limit. Under-reporting is the conservative error,
and
window_sourcemakes it visible rather than silent.