Skip to content

Phase 3 Design Handoff — Decisions Requiring Approval

  • Date: 2026-08-02
  • Status: Approved (2026-08-02, ADR-0009) — Phase 3 gate closed; one change request accepted (the transcript tail window is an implementation constant)
  • Purpose: present the engineering decisions that require approval, with rationale. This is a decision summary for review, not an implementation plan; the detailed specifications live in 0106.

1. Display contract

Final decision. The statusline renders one bounded, deterministic line whose route segment is provider/model (e.g., opencode/big-pickle). When the provider is unknown it shows model only; before the first assistant message it shows the requested label (auto/cheap); past the 5-minute staleness threshold it appends [stale]. The route segment truncates at 32 characters (model portion ellipsized, provider prefix kept). The existing git-branch and context-usage segments are preserved unchanged.

Alternatives considered. (a) Requested→resolved arrow form (auto/cheap → big-pickle): shows both but is verbose and adds width. (b) Provider-only display: compact, but loses the model, which is the finer-grained disambiguator (several models per provider). (c) Model-only, dropping provider: fails the project's core provider-aware objective. (d) Keep the old requested-model display: does not meet the objective.

Why chosen. The resolved route is the informative value; the requested label is what the user configured and is a fallback, not the point. provider/model is compact, deterministic, and carries both facts in one token. Ellipsizing the model (not the provider) preserves the most useful disambiguator.

Trade-offs. Slash form reads ambiguously for model strings that already contain / (e.g., deepseek-ai/deepseek-v4-pro renders nvidia/deepseek-ai/deepseek-v4-pro); accepted for determinism — model strings are never parsed. Truncation hides the tail of very long model ids. The [stale] marker is a soft signal, not a guarantee that the next request re-routes.

Assumptions. The transcript yields the resolved model whenever an assistant message exists (verified: 78/78). The 32-char budget covers the observed catalog and is a configurable constant. Provider, when shown, is right most of the time (heuristic, ADR-0006).

Risks. Long model ids exceed the budget and lose distinguishing suffixes (mitigated: provider prefix + model head kept). A wrong provider from the heuristic appears on screen (mitigated: display-only, degrades to model-only). Determinism could break only if we rendered payload fields we don't control — we render a fixed set, so it holds.

Impact on implementation. A compose step (route from transcript + mapping + payload) plus a truncation helper; the git/context segments are untouched. Deterministic output makes the route segment unit-testable with fixture payloads and transcripts.


2. Input/output contract

Final decision. Consume a fixed, verified subset of the documented stdin payload: model.display_name || model.id (requested label), transcript_path, cwd, context_window.*, session_id (diagnostics only). Read a bounded tail of the transcript once per invocation (the window size is an implementation constant — currently 256 KB), walk backward to the last assistant message, resolve the model at message.model with fallback to top-level model. Output is a single line (no trailing newline, two-space segment joins); empty string on unparseable stdin or total failure.

Alternatives considered. (a) Read the whole transcript each event: unbounded and grows with the session. (b) Tail one line only: misses the case where the last line is not an assistant message. (c) Query SQLite for the model: slower, Docker-dependent, shared-gateway ambiguous — rejected as primary (ADR-0005). (d) Use the payload's model field: it is the requested label, not the resolved route — the very gap this project exists to close.

Why chosen. The transcript is the only verified session-local source of the resolved model, sub-ms to read, no auth or Docker, and defensively parsed (ADR-0008). A bounded tail read keeps latency flat as sessions grow; the concrete window size is an implementation constant, not an architectural invariant. The payload stays the fallback and the source of context fields.

Trade-offs. The contract couples the statusline to the transcript's JSONL shape (mitigated by the dual-field read and skip-malformed-line behavior). The bounded tail is a heuristic: if the last assistant message falls outside the tail window, the read misses it (rare; degrades to requested label). The concrete window size is an implementation choice.

Assumptions. The last assistant message is within the tail window for realistic session sizes (the current 256 KB choice covers the observed ~400 KB session). transcript_path is always present and readable (verified). Malformed lines are rare and safe to skip.

Risks. Transcript schema drift (already observed once) relocating the field again — mitigated by checking both locations and degrading to "no resolved model." Long sessions pushing the last assistant message out of the tail window — accepted, falls back to requested label.

Impact on implementation. A single parser module with a precise contract; the backward-walk is the most intricate logic and should be the first thing built with tests, against fixture JSONL.


3. Provider enrichment strategy

Final decision. Provider display is best-effort (ADR-0006), sourced from an empirical model→provider mapping file (~/.claude/statusline-model-provider.json, versioned JSON) read once per invocation (sub-ms). Unknown model → model-only display; a provider is never fabricated. SQLite call_logs enrichment is a documented optional enhancement, off by default, gated on a pre-baked sqlite3 reader image existing.

Alternatives considered. (a) SQLite enrichment as the default (timestamp-correlated call_logs.provider): authoritative, but docker run startup is 0.21–1.13 s against a 300 ms debounce, requires Docker, is shared-gateway noisy, and is non-deterministically gated by the auto/cheap classifier. (b) No provider at all: meets the model core but fails the provider-aware objective. (c) Parse provider out of the model string: rejected — model strings vary by provider and deepseek-ai/deepseek-v4-pro shows / is not a reliable separator.

Why chosen. The mapping is fast, local, free of Docker and classifier dependencies, and satisfies the objective for the observed catalog (unambiguous in current data). SQLite stays architecturally subordinate and can be layered on later without changing the core contract.

Trade-offs. The mapping is a heuristic that can go stale (provider adds/removes a model → wrong provider on screen). It needs out-of-band maintenance (a refresh script); the file is not self-updating. SQLite enrichment, when built, reintroduces latency and gating risk that must be re-validated.

Assumptions. The observed catalog is representative and changes slowly. The mapping can be regenerated from call_logs by a read-only maintenance script. Provider correctness is not a hard requirement (ADR-0006).

Risks. Catalog drift yielding a wrong provider (mitigated: display-only, model-only fallback, generated_at recorded for visibility). A stale, un-refreshed mapping (mitigated: documented maintenance task; a miss simply shows model-only).

Impact on implementation. A small JSON read + lookup; a refresh script in Scripts/ (Phase 4); SQLite enrichment explicitly excluded from the first implementation. The heuristic caveat must be surfaced in Phase 6 documentation.


4. Caching and timeout policy

Final decision. The statusline is stateless per invocation (Claude Code spawns it per event at a ~300 ms debounce). Per-read budgets: transcript tail 250 ms, mapping file 50 ms, each with a hard timeout and a defined fallback (transcript timeout → requested label; mapping timeout → provider omitted). SQLite enrichment, if enabled, gets its own 250 ms budget and must tolerate cancellation. Nothing is ever written; there is no cross-invocation state.

Alternatives considered. (a) A persistent daemon or cache across events: rejected — the platform model is per-event spawn; a daemon adds state, failure modes, and scope far beyond a statusline. (b) Rely on the OS page cache and read the whole transcript: unbounded. (c) SQLite as the provider path with no timeout: exceeds budget and can hang.

Why chosen. The per-event model is the platform contract; the design works within it. Sub-ms reads make caching unnecessary — the only artifact that behaves like a cache is the mapping file, refreshed out-of-band. Hard timeouts guarantee the statusline never blocks or breaks the display.

Trade-offs. Every event re-reads the transcript (cheap but redundant). No history across events (consistent with ADR-0007's most-recent-route display). Mapping staleness is the only invalidation concern, handled out-of-band.

Assumptions. The 300 ms debounce is the operative budget (documented constraint, verified). Plain file reads are not classifier-gated (verified) — so the default path is immune to the auto/cheap classifier being down.

Risks. If Claude Code changes debounce/cancellation behavior, the SQLite path becomes unusable (already true today — hence off by default). Timeout constants are design values to be tuned in Phase 5.

Impact on implementation. No persistence, no cache, no daemon — the smallest possible footprint. Hard-timeout + fallback logic is a small, testable concern.


5. Security and privacy model

Final decision. Read-only by construction. The statusline reads only the metadata allowlist (model, provider, combo_name, status, timestamp, requested_model, combo_step_id) and never tokens, prompts, server.env, api_keys, or artifact bodies. Output is bounded to the display segments and never renders sensitive content. No network calls. OmniRoute labels are untrusted display data — rendered, never executed. The default read path is classifier-independent (plain file reads).

Alternatives considered. (a) Reading transcript message content for context: rejected outright — that is prompt/sensitive content. (b) The OmniRoute HTTP management API: rejected — requires a credential absent from local config (verified 403) and is outside the approved architecture. (c) Caching metadata to disk: rejected — the design never writes.

Why chosen. The architecture's trust boundary (OmniRoute = untrusted label source; read-only) is carried directly into the design. Metadata-only reads minimize exposure and the blast radius of any bug. No network means no exfiltration vector.

Trade-offs. Metadata-only display means the statusline shows only client-side context (context_window from the payload), not OmniRoute-side history. The render-never-execute rule on labels must be honored by implementers.

Assumptions. The metadata allowlist is sufficient for all displayed values (it is — every display value maps to it). The statusline output channel is the standard, safe contract.

Risks. A future feature request for richer display (e.g., OmniRoute-side cost) would expand the allowlist and must be re-reviewed against this model. Label injection (a routed model/provider string crafted to resemble an instruction) is contained by the render-only rule.

Impact on implementation. Enforced by reading only named fields; a negative contract test (no sensitive content in output) is required. No sandboxing is needed because the design never reads sensitive data.


6. Acceptance criteria

Final decision. Approval rests on seven observable invariants: (a) correctness — the route segment equals the last assistant message's resolved model (+ provider when known); (b) determinism — identical inputs produce identical output; (c) latency — the default path completes in <50 ms; (d) boundedness — single line, bounded length; (e) privacy — no sensitive content in output and no writes; (f) resilience — never crashes or emits error lines across missing, malformed, stale, or unavailable inputs; (g) cancellation tolerance — killable at any point without corrupting files. These are encoded as 16 concrete test cases mapped to the Phase 5 validation scenarios.

Alternatives considered. (a) Prose review with validation deferred: rejected — the project's evidence discipline requires testable criteria now. (b) Freezing brittle numeric thresholds: rejected — latency and staleness are constants to tune in Phase 5; acceptance states the invariant, not a number. (c) Omitting negative/privacy criteria: rejected — the security model is only meaningful if tested.

Why chosen. The criteria are observable invariants Phase 4 code can be checked against and Phase 5 can validate in real scenarios. Because they map 1:1 to the roadmap's Phase 5 scenarios, acceptance now predicts validation later.

Trade-offs. Determinism is a stronger requirement than strictly necessary but is cheap and aids testing. A <50 ms criterion is deliberately generous relative to measured sub-ms reads, to avoid flaky CI.

Assumptions. The 16 cases cover the architecture's failure table (every failure condition has a case). Phase 5 confirms real-world latency and display-width behavior.

Risks. A case the suite did not anticipate surfaces in Phase 5 — the validation loop catches it and criteria are reviewable then. Determinism holds because the display renders only a fixed field set.

Impact on implementation. The test list is the Phase 4 contract: implementation is done when all 16 cases pass. Cases 14–16 (no secrets in output, no writes, classifier-gating) are the ones most often skipped and are flagged non-negotiable.


Decision summary for approval

# Area Decision in one line
1 Display Single line provider/model, model-only when provider unknown, requested label before first message, [stale] past 5 min, 32-char truncation
2 I/O Payload subset + bounded transcript tail (window size is an implementation constant); message.model → top-level model; empty output on failure
3 Provider Empirical mapping file is the default source; SQLite enrichment off by default
4 Caching/timeout Stateless per event; hard per-read budgets with fallbacks; no writes, no daemon
5 Security Metadata-only reads, read-only, no network, labels rendered never executed
6 Acceptance 7 invariants → 16 test cases mapped to Phase 5 scenarios

Gate. Approving these decisions closes the Phase 3 gate; the approval is recorded in Decisions.md (Phase 3 ADR). Requests for changes on any numbered decision are the actionable output of this review.