Issue: #223 Upstream:
- anthropics/claude-code#66728 — Classifier-driven served-model swap is invisible to operators (six reported occurrences)
Priority: P1
Branch: feature/statusline-served-model-divergence
Stage: directive — round 5 (Codex r3 REQUEST_CHANGES at 58d41e4 flagged residual literal text — the superseded phrase still appeared in two historical/narrative spots; r5 removes those final residuals so the old phrasing no longer appears anywhere in sticky-clear-related prose. Prior: Codex r2 at b51d690 flagged the sticky-clear wording contradiction (r4 fixed substantively); Codex r1 at b14d90f REQUEST_CHANGES (r3 fixed); Fable r1 at 9e0d58a REQUEST_CHANGES (r2 fixed).)
Labels: directive-stage, P1, schema-change (new persisted per-session fields + new statusline contract)
Milestone: v4.3.0
Surface, in real time, the moment the served model on a CC session diverges from the requested model — the classifier-driven swap pattern documented in CC#66728. Today this is invisible until someone inspects a transcript or grep'd usage row hours after the fact. The proxy already sees both ends (request body model and response stream event.message.model on message_start); the statusline already reads from per-session JSON. The directive extends cache-telemetry to capture + persist divergence state with a sticky latch, and extends tools/quota-statusline.sh to render an indicator when divergence is recent or sticky.
This is the first real-time operator surface for a class of bugs (classifier-triggered downgrades) that has been a recurring upstream issue without a UX path to observe it.
Today, when CC's safety classifier swaps the served model mid-session (Fable → Opus 4.8, Sonnet → Haiku, etc.), there is no operator-visible signal that the swap occurred. The user has to either notice the in-TUI banner in real time, or inspect the session JSONL after the fact, or read the model field of a usage.jsonl row. None of those is a live surface. The result is sessions like gowy222's six occurrences on CC#66728, where the downgrade is detected hours into a downgraded run instead of when it lands.
The proxy already sees requested_model (request body) and served model (response). Two fields, one comparison per turn. The statusline already reads from ~/.claude/quota-status/sessions/<id>.json and renders whatever cache-telemetry writes there. Total work is two small additions (writer + reader) on already-load-bearing components.
Falk-flagged adjacent: this also pairs with the May 9 [5] item (latched-headers observability) and the in-flight extended-cache-ttl-header local test in the sense that both expand the proxy's observability surface to cover server-driven session state CC doesn't expose.
- Size/complexity budget: ~300–400 LOC total — writer-side detector + module-scope pair-keyed map + rehydration-from-disk + schema additions (~180 LOC in
proxy/extensions/cache-telemetry.mjs, up from r1's 150 to absorb rehydration + pair-key bookkeeping per Fable r1 B1/B2 fixes), statusline rendering block + label-and-color logic (~50 LOC of bash + embedded Python intools/quota-statusline.sh), plus tests (~170 LOC including the new restart-rehydration and pair-keyed-counter-isolation cases). Reviewers should flag implementations materially larger than ~2× this (closes Fable r1 nit 6). - Threat model: the new persisted fields are short string scalars (model names, ISO timestamps, booleans). No user-provided content goes anywhere new; comparison is local string equality on already-flowing fields. Statusline renders only the model names, which are non-secret. No new external surfaces, no new headers, no new env vars beyond what cache-telemetry already uses.
- Maintainability constraints: the divergence detector is a small pure function plus a per-session counter map. No new abstractions; reuses existing
metaplumbing,sessionFilenameresolution, and the per-session JSON spread pattern. No CI/config changes. Statusline render-block extends the existing TTL/hit-rate block — no new tool, no new shell file. - Performance/reliability: trivial. Two extra field reads from
ctx.body/event.messageper response, one map lookup, one map write. Statusline reader gains four field reads + a conditional render branch. Failure isolation: the detector lives inside cache-telemetry's existing try/catch around the writer (cache-telemetry.mjs:263-270), so a malformed value cannot break the pipeline. Statusline already tolerates absent fields (existingsess.get('cache', {})precedent) — additive schema reads safely default to None /[]. - Load-bearing? Yes. Adds a new persisted contract on the per-session JSON (
requested_model,served_model,model_divergence_recent,model_divergence_sticky,model_divergence_first_seen) and a new statusline rendering contract. By CLAUDE.md's rubric (wire/schema contract + shared abstraction touching the operator observability surface), this qualifies as load-bearing. Per CLAUDE.md, load-bearing changes require Chris human review before merge in addition to Lead + Codex review.
-
Show only on divergence. Normal-operation default: no extra field in the statusline. When
requested_model ≠ served_modelon the most recent turn, render a divergence indicator. When neither recent-divergent nor sticky, render nothing. -
Sticky state on session-permanent downgrade. A single divergent turn followed by a return-to-requested could be a transient. A divergence that persists past the sticky threshold (see §Heuristic below) latches sticky for the remainder of the session — the bar keeps warning even if subsequent turns happen to align by coincidence. Sticky state is observable in the session JSON (
model_divergence_sticky: true) so other consumers can read it without re-deriving from the request stream. -
Short labels with [1m] indicator.
- Family-derived short labels per the table below.
- When
[1m]context is in use, append[1m]to the requested side only (e.g.,Opus 4.7[1m] → Opus 4.8). Source:auto_1m_detectedfield already on the per-session JSON via_auto1mGuardspread (cache-telemetry.mjs:255, set byauto-1m-guard.mjs:104-108). The flag reflects the outbound request header (auto-1m-guard.mjs:11-12— CC strips[(1|2)m]frombody.modelbefore the wire), so it can only assert "1m was requested." The served side's 1m status is unknown to the proxy (the response carries no 1m signal we can use), and cross-family swaps almost certainly drop 1m on the served side — rendering[1m]on the served label would assert something we don't know (closes Fable r1 nit 1). - Divergence renders as
requested → served, e.g.,Fable → Opus 4.8.
-
Attention-grabbing color. Recent-divergent renders red (
\033[31m) matching the existingTTL:5mwarning atquota-statusline.sh:192. Sticky state renders with explicit black-foreground + yellow-background (\033[30;43m...\033[0m) — distinct enough to signal "this is now persistent" without conflicting with the red used for transient signals, and the explicit foreground keeps the text legible on light/yellow-ish terminal themes where the default fg might collide with the yellow bg (closes Fable r1 nit 5). Reviewers can propose alternative color choices; the only constraint is that the two states be visually distinct from each other and from the existing red. -
Cost-pool axis (deferred to post-2026-06-15). Once the SDK pool split lands, the bar should also expose which billing pool the recent turn drew from. Out of scope for this directive's initial PR; flagged for follow-up. Field-naming should leave room: a future
cost_poolfield on the per-session JSON is the obvious continuation, but no shape is committed here.
The issue proposes "3 consecutive divergent turns OR a span of ≥5 minutes, whichever lands first" but invites better proposals. The directive recommends a family-aware split:
- Cross-family divergence → immediate sticky on the first divergent turn. Examples: Fable → Opus, Sonnet → Haiku, Opus → Sonnet. Cross-family swaps are very rarely innocent; they almost always indicate a classifier action worth surfacing immediately. Letting the user wait 3 turns to see this when CC#66728 is the existence proof is the wrong UX trade-off.
- Same-family divergence → 3-consecutive-turn counter before latching. Example: Opus 4.7 → Opus 4.8 is far more often a legitimate version routing event (rollout, A/B). Three consecutive turns matched at the same
(requestedModel, servedModel)pair eliminates transients without an unnecessary stickiness on cross-version routing. Counter state is keyed by(sessionFilename, requestedModel)and storesservedTargetso "same target" is explicit and verifiable (closes Fable r1 B2 — session-only keying was unimplementable for "same target" and would have been reset by interleaved background utility calls). - Counter reset semantics (closes Fable r1 B2):
- A matched turn at the same
(sessionFilename, requestedModel)resets the counter for that pair only — other pair entries are untouched. This isolates main-model state from background utility calls (haiku title generation, etc.) that interleave at a differentrequestedModel. - A divergence at a different
servedTargetfor the same(sessionFilename, requestedModel)resets the counter (or, equivalently, starts a new pair entry under the new target). Cross-target flapping does not accumulate. - A
/modelinvocation that changes therequestedModelfor subsequent turns starts a fresh pair entry. Prior pair entry's state lives only in the in-memory map for the lifetime of the process — it does NOT survive proxy restart. A return to the originalrequestedModelwithin the same process resumes from where it left off; a return after restart starts fresh for that pair.
- A matched turn at the same
- Sticky never auto-unlatches within a session — and the map is authoritative across proxy restarts for the currently-active
requestedModelpair only (closes Fable r1 B1; closes Codex r1 B1). The per-session JSON is the rehydration source for ONE pair: on map-miss (first turn after proxy restart, fresh process), the writer readssessionFilePath(rawSid).json(precedent:sessionFilePathis exported atcache-telemetry.mjs:55-57for exactly this read pattern). Rehydration only seeds a map entry when the persistedrequested_modelfield on disk equals the current turn'sctx.telemetry.requestedModel— if they differ (e.g. the session is now on a different/modelthan when sticky last latched), the writer treats it as a fresh entry for the new pair and does NOT inherit the persistedsticky/served_model/first_seenfields. This narrow rehydration contract avoids the cross-pair pollution Codex r1 flagged: persisted state was always single-tuple, so seeding ANY new pair from it would risk inheriting unrelated sticky into the wrong key. Dormant pair counters explicitly do NOT survive restart; that's documented as a known limitation rather than fixed by persisting per-pair state (which would expand the schema for an edge case unlikely to matter in practice — a session that goesA → /model B → restart → /model Ais rare and the worst case is "sticky restarts at fresh forA," not a wrong indicator). - Clearing sticky requires removing both the persisted JSON record AND the in-memory map entry. Deleting the per-session JSON file by itself does NOT clear sticky — the in-memory map will re-emit the persisted fields on the next turn. To actually clear sticky within an active session, the operator must (a) delete the per-session JSON file AND (b) cause the in-memory map entry to drop, either by restarting the proxy or by waiting for the time-based stale-session sweep. A new session also works (fresh session id = fresh map key + no prior file). Pick whichever is operationally cheaper; both end states are equivalent. (Closes Codex r2 contradiction on the sticky-clear contract: the prior phrasing implied a new session was the only recovery, but the same-session delete-plus-evict path is also valid.)
- The 5-minute span suggestion from the issue is dropped. Turn-count is the only signal that doesn't require persisting timestamps across stream events on the writer side; introducing a wall-clock branch increases complexity without materially better detection.
The family map is hard-coded in cache-telemetry.mjs (or a small adjacent helper):
fable → fable
mythos → mythos
opus-4-7 → opus
opus-4-8 → opus
sonnet-4-6 → sonnet
sonnet-4-7 → sonnet
haiku-4-5 → haiku
Note: no [1m] suffix variants in the family map. Per auto-1m-guard.mjs:11-12, CC strips [(1|2)m] from body.model before the wire — neither requestedModel (request body) nor event.message.model (response) ever carries the suffix. The [1m] indicator is a render-side suffix driven separately from auto_1m_detected.
Unknown model strings fall through to "unknown" family and are treated as same-family for purposes of the counter (conservative — avoids latching sticky on a yet-unseen model). The family map is the only piece of business logic that needs to update when Anthropic ships new models; documented as such in CHANGELOG and in the code site.
Sessionless / unknown-session bucket (closes Fable r1 nit 4): per cache-telemetry.mjs:64-72, requests missing a session-id header bucket to the literal "unknown" sessionFilename and share a single per-session JSON file. Their pair-keyed map entries therefore also share state across genuinely distinct clients in this bucket. This is precedented by the existing cache block on the same file and is documented as a known limitation; impl PR call-out in CHANGELOG. Operators relying on the divergence indicator should ensure their CC is sending the session-id header (which 2.1.x does by default).
| Model id (substring match) | Short label |
|---|---|
fable |
Fable |
mythos |
Mythos |
claude-opus-4-7 |
Opus 4.7 |
claude-opus-4-8 |
Opus 4.8 |
claude-sonnet-4-6 |
Sonnet 4.6 |
claude-sonnet-4-7 |
Sonnet 4.7 |
claude-haiku-4-5 |
Haiku 4.5 |
| anything else | the raw model id (operator fallback so unknown models are still legible) |
[1m] suffix appends to the requested side only of the → when auto_1m_detected is true on the current per-session JSON. See Functional Requirement §3 for rationale.
-
Request-side source — already exists. Fable r1 A2:
server.mjs:127parsesrequestedModel, ANDserver.mjs:193already stashes it astelemetry.requestedModel.cache-telemetry.onStreamEventalready destructurestelemetryatcache-telemetry.mjs:191-192.usage-log.mjs:295is the established precedent reader:const requestedModel = ctx.telemetry?.requestedModel || undefined;. The directive reads from this existing channel — noserver.mjsmodification, no newmeta._requestedModelplumbing. (R1's proposed server change is dropped.) -
Response-side capture —
message_start, outside the usage guard. Inside themessage_starthandler atcache-telemetry.mjs:194, stashctx.meta._servedModel = event.message?.modelbefore theevent.message?.usageguard at the same site — a usage-lessmessage_start(cancelled response, edge cases) must not silently skip the served-model capture (closes Fable r1 nit 3). This is a pure assignment; it cannot meaningfully throw. -
Detection runs at
message_delta, inside the existing try/catch. Move the comparison, family lookup, and map mutation into themessage_deltabranch atcache-telemetry.mjs:203— specifically, inside the existingtry {}block atcache-telemetry.mjs:263-270so the failure-isolation claim is true as written (closes Fable r1 A1). At that point,ctx.telemetry.requestedModelandctx.meta._servedModelare both available; the result object is stashed onctx.meta._modelDivergencefor the per-session JSON build at:225-261to consume via the spread idiom. -
Module-scope state — pair-keyed, rehydrated from disk.
cache-telemetry.mjsalready holds module-scope state (legacyCleanupDone,lastSweepMs). Add aMap<string, { servedTarget, divergentTurnCounter, sticky, firstSeenIso }>where the map key is the composite${sessionFilename}|${requestedModel}(closes Fable r1 B2 — the previous session-only keying was unimplementable for "same target" and would have been polluted by interleaved background utility calls).Rehydration on map-miss (closes Fable r1 B1; closes Codex r1 B1): when the writer encounters a
(sessionFilename, requestedModel)pair with no map entry, it readssessionFilePath(rawSid).json(precedent:sessionFilePathis exported atcache-telemetry.mjs:55-57precisely for sibling-reader access). Rehydration is guarded onrequested_modelequality: the persistedrequested_modelfield MUST equal the current turn'sctx.telemetry.requestedModelfor seeding to occur. If they match, the entry is seeded from persistedmodel_divergence_sticky+model_divergence_first_seen+served_modelfields (sticky: truerehydrates immediately so the next emit carries the sticky spread — indicator survives proxy restart for the active pair). If they don't match, OR if the file is missing, OR if the disk read throws, the writer treats it as a fresh entry for this pair — no inherited sticky, no cross-pair pollution. Rehydration is best-effort and never throws into the pipeline.Time-based eviction (closes Codex r1 attention): the existing
sweepStaleSessionsatcache-telemetry.mjs:132-156is a TTL-based stale-session sweep, not access-order LRU. The directive extends it (or adds a sibling map-sweep at the same throttled cadence) to drop map entries whosesessionFilenamematches an evicted-from-disk session (i.e. the file got swept).__resetForTests(cache-telemetry.mjs:275) clears the map for unit tests. -
Schema additions in the per-session JSON object built at
cache-telemetry.mjs:225-261. The new fields piggyback on the same spread idiom used by_thinkingSanitize,_thinkingSanitizeV2,_auto1mGuard,_sessionHealth:
...(ctx.meta._modelDivergence || {}),where _modelDivergence is the object produced by the writer side and shaped as:
{
requested_model: string,
served_model: string,
model_divergence_recent: boolean,
model_divergence_sticky: boolean,
model_divergence_first_seen: string | null, // ISO timestamp set when sticky latches; null otherwise
}
When requested_model === served_model AND no prior sticky state for the active (sessionFilename, requestedModel) pair, _modelDivergence is left undefined and the spread is a no-op. When a matched turn occurs but sticky was previously latched for that pair, _modelDivergence carries the sticky flag + first-seen + matched (recent: false, sticky: true) so the statusline keeps warning. The two booleans are the load-bearing signal; the timestamps and strings are display payload.
- Failure isolation — actually true as written now. All detection logic at
message_deltaruns inside the existingtry {}block atcache-telemetry.mjs:263-270. Themessage_start_servedModelstash is a pure assignment outside any try/catch but cannot throw. A bad model string, a missing field, a map exception, or a rehydration disk-read failure all drop the divergence record on the floor; the pipeline continues. The pipeline's per-hook catch atpipeline.mjs:115-127is the secondary backstop, not the primary. NounhandledRejection. No client-visible behavior change.
After the existing TTL/hit-rate render at quota-statusline.sh:188-196, add a conditional render block. The script's structure is bash + heredoc'd Python (<<'PYEOF' to disable bash interpolation per the v3.5.2 security note at quota-statusline.sh:12-22). New code lives in the heredoc, NOT in bash — the security boundary stays intact.
Render rules:
recent = sess.get('model_divergence_recent', False)sticky = sess.get('model_divergence_sticky', False)- If neither, render nothing.
- If
recentand notsticky: render' | ' + red(short(requested) + ' → ' + short(served)). - If
sticky(regardless ofrecent): render' | ' + yellow_bg(short(requested) + ' → ' + short(served)). [1m]suffix fromauto_1m_detectedon the requested side only (per Functional Requirement §3, the table, and the reviewer checklist — the proxy has no signal for served-side 1m, and cross-family swaps almost certainly drop 1m on the served side).
Short-label function follows the table above. Unknown models pass through verbatim — the operator still gets information.
proxy/server.mjs— no change. R1 proposed a one-line stash; r2 confirmstelemetry.requestedModelalready flows via the establishedusage-log.mjs:295precedent. No new plumbing.proxy/pipeline.mjs— no change.proxy/extensions.json— no new extension (this is a feature inside cache-telemetry).proxy/extensions/usage-log.mjs— no change. TheextractMessageStartFieldshelper already exposesmodelfor any later consumer.proxy/extensions/auto-1m-guard.mjs— no change. Theauto_1m_detectedfield already flows correctly.- Pre-existing
cacheblock schema (ttl_tier,hit_rate, etc.) — unchanged.
- Detector unit tests (
test/proxy-cache-telemetry-model-divergence.test.mjs, new file):- Matched turn (no divergence) — no
_modelDivergencefield; pair-keyed map unchanged. - Cross-family swap (Fable → Opus 4.8) — immediate sticky;
recent: true, sticky: true, first_seen: <iso>; map entry under(session, "fable...")withservedTarget: "claude-opus-4-8". - Same-family swap × 1 (Opus 4.7 → Opus 4.8) —
recent: true, sticky: false; counter = 1;servedTarget: "claude-opus-4-8". - Same-family swap × 2 —
recent: true, sticky: false; counter = 2. - Same-family swap × 3 (at the same
servedTarget) —recent: true, sticky: true, first_seen: <iso>. - Same-family swap × 2 at one target then × 1 at a different target — counter resets / new pair entry; no sticky (cross-target flapping does not accumulate, per the heuristic's reset semantics).
- Sticky persists across subsequent matched turn —
recent: false, sticky: true, first_seenunchanged. - Same-family swap × 2 then matched turn at the same
requestedModel— counter resets to 0, no sticky. - Pair isolation (Fable r1 B2): divergent turn at
requestedModel = "claude-opus-4-7"→ matched turn atrequestedModel = "claude-haiku-4-5"(simulates interleaved background utility call) → divergent turn atclaude-opus-4-7again. Opus pair counter advances 1 → 1 → 2 (the haiku matched-turn does NOT reset the opus counter; pair isolation verified). - Restart rehydration (Fable r1 B1): persist a per-session JSON with
model_divergence_sticky: trueandrequested_model: "claude-opus-4-7"; reset the module-scope map (__resetForTests); next turn at the samerequestedModel = "claude-opus-4-7"produces a map-miss, the writer rehydrates from disk, and the next emit carriessticky: trueeven though no new divergence happened. Sticky survives restart for the active pair. - Restart +
/modelchange rehydration guard (Codex r1 B1): persist a per-session JSON withmodel_divergence_sticky: trueandrequested_model: "claude-opus-4-7"; reset the module-scope map; next turn arrives atrequestedModel = "claude-sonnet-4-6"(operator did/modelpost-persist, pre-restart). Writer reads the file, sees therequested_modelmismatch, treats this as a fresh pair entry — NO sticky inherited into the sonnet pair. The persisted opus state is left on disk but does NOT contaminate the new pair. Documents that dormant pair counters do not survive restart. - Rehydration disk-read failure is non-fatal: ENOENT on the per-session JSON during rehydration → fresh entry, no exception, pipeline continues.
- Unknown-family model — treated as same-family (counter-based latching); no immediate sticky.
/modelmid-session (differentrequestedModelfor subsequent turns) — fresh pair entry under the new requested model; prior pair entry retained but inactive; return to originalrequestedModelresumes from prior state.
- Matched turn (no divergence) — no
- Integration (extend
test/proxy-cache-telemetry.test.mjs): synthesize a request with bodymodel: "claude-opus-4-7"(whichserver.mjs:127/:193puts ontelemetry.requestedModel) and amessage_startwithevent.message.model: "claude-opus-4-8"; assert the spread fields appear on the written per-session JSON. - Statusline rendering (extend
test/quota-statusline-smoke.test.mjs— the existing harness onorigin/main): no divergence → no extra field; recent divergence → redrequested → served; sticky → black-on-yellow-background treatment;[1m]suffix appears on the requested side only whenauto_1m_detected: true; served-side[1m]is never rendered. Tests invoke the script with synthetic per-session JSON files and assert on the rendered string. - Failure-isolation: feed a malformed
event.message(missing.model, non-string.model) — assert no exception escapes themessage_deltatry/catch, no_modelDivergencefield written, per-session JSON write succeeds for the other fields. Also:message_startwith no.usage(cancelled response) still stashes_servedModelcorrectly (nit 3 regression test).
node --test test/proxy-cache-telemetry-model-divergence.test.mjs— all green.node --test test/proxy-cache-telemetry.test.mjs— still green; no regression.- Statusline test green on both default-rendering case (no divergence) and the new cases.
- Manual smoke: with the proxy running on a dev host (see internal deployment notes), drive a session through
/model fablethen watch for the safety-classifier swap event. The statusline should show the divergence within one turn. Cross-family swap should immediately render the sticky treatment. gh pr view <impl-pr>showsneeds-sim-validationcapture if any envelope-shape parity is in scope (no new envelope here; sim is unlikely required, but reviewers can call it).
Created:
test/proxy-cache-telemetry-model-divergence.test.mjs— new unit tests for the detector.- Render tests added by extending
test/quota-statusline-smoke.test.mjs.
Modified:
proxy/extensions/cache-telemetry.mjs— divergence detector (atmessage_deltainside try/catch) + pair-keyed module-scope state map + rehydration-from-disk + schema spread +_servedModelstash atmessage_start(outside usage guard).tools/quota-statusline.sh— render block after the existing TTL/hit-rate section; reads new fields, renders red recent / black-on-yellow sticky,[1m]suffix on requested side only.test/proxy-cache-telemetry.test.mjs— new integration test covering the spread.CHANGELOG.md— v4.3.0 entry citing CC#66728 + #223; calls out the family-map maintenance burden.README.md— statusline feature callout listing the divergence indicator (one-line addition).docs/quota-statusline.md(if present) — render-rule table.
Out of scope (no changes):
proxy/pipeline.mjs,proxy/stream.mjs— no pipeline hook changes.- Cost-pool field — deferred to post-2026-06-15.
- Preload-mode shim — preload writes the flat
quota-status.json, not the split layout. The preload parity update would need a parallel change once the proxy-mode version lands; tracked as a follow-up.
-
requestedModelis read fromctx.telemetry?.requestedModelat themessage_deltasite — noserver.mjsmodification, no newmeta._requestedModelplumbing. (Fable r1 A2.) -
_servedModelstash atmessage_startis outside theevent.message?.usageguard so cancelled / usage-less responses still capture the served model. (Fable r1 nit 3.) - Detection (comparison + family lookup + map mutation) runs at
message_deltainside the existing try/catch atcache-telemetry.mjs:263-270, so the failure-isolation claim is true as written. (Fable r1 A1.) - Family map is one named constant, updated only when Anthropic ships a new family. No
opus-4-7[1m]row (CC strips[(1|2)m]frombody.modelbefore the wire perauto-1m-guard.mjs:11-12). (Fable r1 nit 2.) - Cross-family swap latches sticky on turn 1; same-family swap requires 3 consecutive divergent turns at the same
(requestedModel, servedTarget)pair. (Fable r1 B2.) - Counter state map keyed by
(sessionFilename, requestedModel)and storesservedTarget; matched-turn reset applies to that pair only; cross-target flapping does not accumulate. (Fable r1 B2.) - Map authoritative + rehydrates from on-disk JSON on map-miss, guarded on
requested_modelequality. Persistedmodel_divergence_stickysurvives proxy restart for the active pair only. If persistedrequested_model≠ currentctx.telemetry.requestedModel, the writer treats it as a fresh entry — no cross-pair pollution. Rehydration disk-read failure is non-fatal (best-effort). Dormant pair counters do NOT survive restart (documented as a known limitation). (Fable r1 B1, Codex r1 B1.) - Per-session state map bounded by the existing TTL-based
sweepStaleSessionscadence (or a sibling map-sweep at the same cadence) — NOT access-order LRU;__resetForTestsclears it. - Schema spread idiom matches
_thinkingSanitize,_auto1mGuard,_sessionHealth— additive, optional, no breaking field deletions. - When
requested_model === served_modeland no prior sticky for this(session, requestedModel)pair,_modelDivergenceis undefined; spread is a no-op. - Statusline render only fires on
recentorsticky; default render path unchanged. - Statusline reads the new fields from per-session JSON only; no new account.json fields.
- Bash heredoc
<<'PYEOF'boundary preserved perquota-statusline.sh:37-41security note. -
[1m]suffix consumesauto_1m_detectedfrom the per-session JSON and renders on requested side only. (Fable r1 nit 1.) - Sticky color escape is
\033[30;43m...\033[0m(explicit black foreground + yellow background + full reset) for light-theme legibility. (Fable r1 nit 5.) - Short-label table matches the directive's table; unknown models pass through verbatim.
- CHANGELOG entry cites CC#66728 + #223 and calls out the family-map maintenance burden.
- Failure isolation: writer-side detector at
message_deltacannot throw out of cache-telemetry's existing try/catch;message_start_servedModelstash is a pure assignment that cannot throw. - Restart-rehydration unit test and pair-isolation unit test both present and green.
- Documents that clearing sticky requires BOTH the persisted JSON file removal AND in-memory map eviction (restart, sweep, or new session). File deletion alone does not work because the map will re-emit on the next turn. (Fable r1 B1 + Codex r2 follow-through.)
- Cost-pool indicator. Deferred to post-2026-06-15 SDK pool split.
- Notification path (PushNotification or equivalent) when sticky first latches — visible-color indicator is sufficient for v1; notification is a possible v2 once we have real-world data on how often it would fire.
- Auto-restore — issuing
/modelto recover from a sticky downgrade is observability, not remediation. Out of scope. - Anything specific to CC#66728's classifier internals. We observe the divergence as a black-box pattern; we do not try to predict it.
- Preload-mode parity for the indicator. The preload writes the flat
quota-status.json, not the split layout (filed as cache-fix #219). Preload parity is a separate change that can land once the proxy version is validated; tracking as follow-up. - Sticky-clear UI. No clear-sticky mechanism inside the statusline. Clearing requires the JSON-file-removal + map-eviction pair documented in §Heuristic; file deletion alone does NOT clear sticky because the in-memory map will re-emit on the next turn.
Per project workflow:
- Qwen first-pass scan
- Fable primary review (design-judgment-heavy — heuristic choice, label conventions, color, sticky-state rules all need a second opinion)
- Codex cross-LLM verification
- AITL plan-approval
- Owner merges (load-bearing: also requires Chris human review)
Fable and Codex both have useful angles to add — divergence-detection heuristics, the family map (additions / corrections), label conventions, color choices, and the eventual cost-pool extension are all design surfaces where second opinions matter.