# v0.6 Retrospective — Chain as a first-class object

*Period: 2026-04-20 (same-day continuation of v0.5 / v0.5-D).*
*Sub-releases: v0.6-A / v0.6-B / v0.6-C / v0.6-D. None are git tags yet; this retro proposes tagging the four as `v0.6.0`.*
*Tests: 267 → **306** (+39, +15%). All green throughout.*

## Scope at a glance

| Sub-release | Theme | Primary deliverable | Tests Δ |
|---|---|---|---|
| v0.6-A | Launch-time profile selection | `--mode` CLI / `CODEROUTER_MODE` env / startup validation for `default_profile` | +8 |
| v0.6-B | Profile-level parameter override | `FallbackChain.{timeout_s, append_system_prompt}` + `ProviderCallOverrides` dataclass | +8 |
| v0.6-C | Declarative `ALLOW_PAID` gate | `chain-paid-gate-blocked` aggregate warn + `ChainPaidGateBlockedPayload` TypedDict | +8 |
| v0.6-D | Intent / implementation namespace separation | `mode_aliases` YAML + `X-CodeRouter-Mode` header → profile | +15 |

The v0.5 retrospective's "Follow-ons" listed three items for v0.6-ish consideration: heuristic table maintenance signal, log payload as TypedDict, and real-machine re-verify. v0.6 consumed the TypedDict suggestion (v0.5.1 A-1 `CapabilityDegradedPayload` → v0.6-C's `ChainPaidGateBlockedPayload` as a second instance of the pattern) and picked up four items from plan.md §9.3 that were explicitly deferred out of v0.5 scope (`--mode` / profile-level timeout / declarative `ALLOW_PAID` gate / mode-header routing). The §9.3 list of v0.5-deferred items is now exhausted except for "capability mismatch → chain skip" (held for v1.0+ because it intersects with the vision capability work).

## What happened, in order

1. **v0.6-A** was the smallest piece but set the fast-fail philosophy for the rest of the minor. Before v0.6-A, `default_profile` was read from YAML but only validated implicitly — a typo surfaced as a `KeyError` on the first request. The fix wired a `@model_validator(mode="after")` into `CodeRouterConfig` that rejects the config at load time with a diagnostic listing the known profile names, and added two override channels (`--mode` CLI flag forwards to `CODEROUTER_MODE`, env wins over YAML, empty env string is treated as unset). The validator pattern set the shape for v0.6-D's parallel `_check_mode_alias_targets_exist`. Small but load-bearing: a broken config now fails before `uvicorn` starts, not on request N.

2. **v0.6-B** added two per-call parameter overrides to `FallbackChain` (`timeout_s` and `append_system_prompt`) with **replace** semantics rather than append. The motivation was concrete: `claude-code-long` needs a 600s timeout on the 14b model and should not inherit `/no_think` from the default provider. The design call worth capturing was "replace, not append" — append semantics for scalars like `timeout_s` is nonsensical, but an argument could have been made for `append_system_prompt` to concatenate. Replace won because mixed semantics across two fields is a debugging trap. An empty-string value for `append_system_prompt` explicitly clears the provider directive (distinguished from `None`, which means "fall through to provider default"). The plumbing introduced a new `ProviderCallOverrides` Pydantic model threaded as a keyword-only arg through `BaseAdapter.generate`/`stream`, with `effective_timeout()` / `effective_append_system_prompt()` helpers on the base class so adapters never branch on profile concepts. `retry_max` was excluded because adapters have no retry mechanism yet — the fallback chain is the retry mechanism, and adding a provider-internal retry loop would create nontrivial interactions with the v0.3-B mid-stream guard.

3. **v0.6-C** applied the v0.5.1 A-1 pattern (TypedDict + chokepoint helper + logging.py residency) to a different log class. The `ALLOW_PAID=false` gate had been enforced since v0.1 but its diagnostics were insufficient: a chain made entirely of paid providers emitted N per-provider `skip-paid-provider` INFO lines and then raised `NoProvidersAvailableError`, so the "the gate emptied the chain" reasoning had to be reconstructed from the log timeline. v0.6-C collects blocked provider names inside `_resolve_chain` and, when the resolved chain is empty **and** blocks were recorded, emits a single aggregate `chain-paid-gate-blocked` WARN with a grep-friendly hint. The choice of WARN vs the v0.5 INFO level reflects the diagnostic severity: chain-empty-by-gate is almost always a misconfig; cap-degraded is a normal operating mode. Non-breaking: the `NoProvidersAvailableError` exception shape is unchanged, and `skip-paid-provider` INFO is preserved per-provider. Placing the emission inside `_resolve_chain` meant all four engine entry points (generate / stream / generate_anthropic / stream_anthropic) got coverage for free — the chokepoint property that made the v0.5-A pattern work, reused.

4. **v0.6-D** was the most conceptually interesting of the four. Before v0.6-D, clients sent concrete profile names (`default` / `fast` / `claude-code-long`) directly, which coupled client configs to router internals. v0.6-D introduces an alias layer: a YAML `mode_aliases:` block maps **intent** names (`coding` / `long` / `fast`) to concrete profiles, and an `X-CodeRouter-Mode` header lets clients (or proxies in front of them) declare intent. Two design calls mattered: (a) **Mode < Profile in precedence** — if the caller named the implementation, the implementation wins over intent. The alternative (Mode overrides Profile) would create proxy-injection hazards where a header added by an upstream gateway silently overrides a body-embedded profile from the caller. (b) **Header only, no body field** — profile has both a body field and a header, but mode is header-only. The reasoning was a scope / cleanliness call: body fields are API-shape contracts, headers are the orchestration lane, and mode fits the orchestration lane by nature (proxies, A/B routing, intent expression). Adding a body field would force both `ChatRequest` and `AnthropicRequest` to grow symmetrically, which is cost without benefit.

## The minor's design through-lines

v0.5 established "capability gate trio" as a named family; v0.6 was looser but four patterns crystallized across the sub-releases and are worth making explicit:

| Pattern | Instances in v0.6 | Cross-minor roots |
|---|---|---|
| Startup fast-fail validator (`model_validator(mode="after")`) | v0.6-A `_check_default_profile_exists` / v0.6-D `_check_mode_alias_targets_exist` | New in v0.6 |
| TypedDict payload + chokepoint helper in `logging.py` | v0.6-C `ChainPaidGateBlockedPayload` / `log_chain_paid_gate_blocked` | v0.5.1 A-1 `CapabilityDegradedPayload` / `log_capability_degraded` |
| `_resolve_chain` as the 4-entry-point chokepoint | v0.6-C paid-blocked list collection | Introduced in v0.4-A when chains became polymorphic (`anthropic` + `openai_compat` mixed) |
| Non-breaking log-lane addition (exception shape unchanged, log adds context) | v0.6-C `chain-paid-gate-blocked` | v0.5.1 A-3 `chain-uniform-auth-failure` |
| YAML-first validation (unknown keys fail at load, not at first request) | v0.6-A default_profile / v0.6-D mode_aliases targets | v0.5-A `capabilities.*` flags (implicitly) |

The first two rows are the most durable: **startup validation** and **typed log payloads** together reduce the "silent misconfig until it breaks in production" class of failure, and both are cheap to reuse. A prospective v0.7 or v1.0 feature that introduces a new YAML block or a new log line should adopt both patterns by default — the alternative ("validate at first use, log with loose dict") is strictly worse on both axes and we've now accumulated four instances where fast-fail + TypedDict paid for themselves.

## What worked

- **Four small sub-releases beat one big one.** Each of v0.6-A/B/C/D touched a disjoint surface (startup lifecycle / adapter param layer / routing log / ingress header) and landed under 300 LOC. Bundling them under one tag now that they're stable is cheaper than attempting them as a single release — the per-sub-release CHANGELOG entries make selective review / bisect straightforward. Same structure as v0.5, different scope.
- **v0.5.1 A-1 pattern held under a new instance.** Writing `ChainPaidGateBlockedPayload` in v0.6-C took minutes, not hours, because `CapabilityDegradedPayload` was the template. The chokepoint helper in `logging.py` (rather than in `routing/capability.py` or `routing/fallback.py`) keeps eager-import cycles at bay and matches where future adapter-originated structured logs will want to live.
- **`_resolve_chain` paid back the v0.4-A polymorphism.** When the chain became mixed-kind in v0.4-A, `_resolve_chain` became the single entry-point-shared resolver. v0.6-C's paid-gate warn placement is a direct dividend: adding the aggregate log in one function covered 4 engine entry points (generate / stream / generate_anthropic / stream_anthropic) and got 4 integration tests for the price of shared plumbing.
- **Startup validation displaced a class of bug.** Four ValidationError cases now surface before `uvicorn` binds: bad `default_profile`, bad `CODEROUTER_MODE`, bad `mode_aliases` target, and implicitly any unknown key in the YAML (via `extra="forbid"` on the Pydantic config models). Operator signal moved left in the timeline.
- **"Replace, not append" semantics for v0.6-B avoided an easy footgun.** The first instinct for `append_system_prompt` was to concatenate the provider's directive with the profile's. Concatenation is plausible in isolation but mixed with `timeout_s` (scalar, replace) it creates a debugging trap where two fields in the same config block behave asymmetrically. Uniform replace semantics + an explicit empty-string "clear" marker avoided that.
- **No SDK dependency, still.** v0.5 observed "the 5-dep bound held"; v0.6 held it too. The pydantic validators, header parsing, and paid-gate log machinery are all standard-library adjacent, modulo FastAPI / Pydantic which were already in the graph.

## What was sharp

- **Mode < Profile was a real design call, not an obvious one.** The intuition "header overrides body" is strong and general, but in a proxy-injection setting it inverts safety: an auth proxy might auto-stamp `X-CodeRouter-Mode: coding` on every request, and if that silently overrides a body-embedded `profile`, callers lose the ability to dissent. The design notes on this are now in both the OpenAI and Anthropic ingress docstrings; worth re-reading if a future minor introduces another routing override layer.
- **`FallbackChain` is starting to accumulate fields.** `name` + `providers` + `timeout_s` + `append_system_prompt` is four fields, with `retry_max` deliberately held. The `Capabilities` "kitchen sink" warning from the v0.5 retro applies symmetrically here. v0.7+ should consider whether profile-level overrides deserve a sub-schema (e.g. `overrides: {timeout_s, append_system_prompt, retry_max}`) before the flat structure calcifies. Not urgent.
- **Doc overhead scaled linearly with sub-release count.** Each of v0.6-A/B/C/D required edits to plan.md state line + release history table + §8 tag index + test-count ASCII chart + §9.3 strike-through row + CHANGELOG top section + README Status + README feature bullet + README `tests: N green` count + "Coming next" trim. That's ~9 doc touchpoints per sub-release × 4 sub-releases = ~36 small edits. Nothing is individually painful but the per-minor doc choreography has a real cost. Worth considering whether a templated "sub-release closeout checklist" would help, or whether a scripted test-count updater from `pytest --collect-only` would shave the most common edit.
- **The `mode_aliases` feature has no example in `providers.yaml` yet.** Decision deferred in v0.6-D to avoid breaking the example in a rush. The same pressure will appear for v0.7 features; we should decide before v0.7-A whether example providers.yaml is **reference** (every feature must be shown) or **minimal** (only what a new user needs on day one). Currently it's drifting toward the latter without a stated policy.
- **No real-machine re-verify in v0.6.** v0.4 and v0.5 both had same-day real-machine verification passes that caught non-trivial issues (404-retryable, reasoning-field leak). v0.6's changes are routing-layer and log-layer — lower risk of surfacing new breakage — but the absence of a re-verify is noted, not excused. The right forcing function is the v1.0 14-case regression suite, which should exercise all four v0.6 features across the chain.

## Follow-ons worth remembering

- **Dotted mode names (`coding.fast` / `coding.thorough`).** Briefly considered in v0.6-D design but left out: flat `dict[str, str]` is fine for the 3–5 modes most operators will declare. If a use case arrives that wants structured modes (e.g. per-tenant mode profiles), revisit. YAGNI held.
- **Per-profile `chain-paid-gate-blocked` hint.** The current `hint` is static: `"set ALLOW_PAID=true, mark a provider paid=false, or add a free provider to this profile's chain"`. Useful, but a profile like `claude-code-direct` probably wants `"set ANTHROPIC_API_KEY and ALLOW_PAID=true"` instead. The helper's `hint=` kwarg already supports per-call override; YAML plumbing would make it declarative.
- **`retry_max`.** Excluded from v0.6-B because adapters have no retry mechanism — the fallback chain is retry. Reintroducing it would require designing the interaction with the v0.3-B mid-stream guard explicitly (does a provider-internal retry consume the guard's "have we shipped first byte?" budget?). Likely a v0.7 or v1.0-adjacent design conversation.
- **v0.5 A-3 `chain-uniform-auth-failure` parallel for capability.** Currently, if every provider in a chain fails for the same structural reason (401s, same non-retryable capability mismatch), we have log narrative for auth uniformity (v0.5.1 A-3) and paid-gate uniformity (v0.6-C) but not for capability mismatch. v1.0's "capability mismatch → chain skip" work (plan.md §9.3 remaining item) is the natural home; capture it as a design note not a separate minor.
- **v0.6 real-machine re-verify.** Ideally one afternoon: (a) Claude Code at `claude-code-direct`, confirm `--mode` CLI picks the profile and startup logs show it; (b) intentionally misconfigure `mode_aliases` to point at a typo'd profile, confirm server refuses to start; (c) intentionally configure an all-paid chain with `ALLOW_PAID=false`, confirm `chain-paid-gate-blocked` fires once with correct `blocked_providers`; (d) send `X-CodeRouter-Mode: coding` and confirm `mode-alias-resolved` INFO shape.
- **Test-count updater.** The test-count ASCII chart in plan.md is edited by hand every sub-release; a three-line shell script that diffs `pytest --collect-only -q | wc -l` against the previous count and emits the delta row would be a one-time cost with a per-release payback.

## Numbers

```
Tests:        267  →  306              (+39, +15%)
LOC (src):    ~4100 → ~4350            (+~250,  +6%, concentrated in schemas.py / fallback.py / ingress/*_routes.py / logging.py)
LOC (tests):  ~6850 → ~7250            (+~400,  +6%, mostly parallel OpenAI/Anthropic ingress coverage)
Commits:      4 on 2026-04-20          (+ docs commit per sub-release)
Runtime deps: 5    →  5                (unchanged)
```

Baselines: v0.5-D closeout (the last commit with 267-test count). LOC numbers are approximate (git stats not recomputed as part of this retro); the commit history is the source of truth.

v0.6's LOC delta is smaller than v0.5's (+250 vs +404) because three of the four sub-releases are structurally tiny — a validator, an override struct, an aggregate log helper. v0.6-D is the only one with a meaningful new surface (schema + validator + two ingress routes + mode resolution logic). Test delta is proportionally larger because each feature was covered with both happy-path and degradation-path tests, and v0.6-D's ingress parallelism doubled its test count (every OpenAI ingress test has an Anthropic ingress twin).

## How to read this

This retro is the primary source for v0.7+ planning. Three constraints worth respecting when adding the next minor:

- **Startup fast-fail is default, not optional.** If a new YAML block can be misconfigured, it gets a `@model_validator(mode="after")` at load time. Four precedents: `default_profile` (v0.6-A), `mode_aliases` targets (v0.6-D), plus the two `extra="forbid"` schemas that have been in place since v0.1.
- **Typed log payloads for any new structured log line.** Two instances (`CapabilityDegradedPayload` v0.5.1, `ChainPaidGateBlockedPayload` v0.6-C); by the third instance this should be a convention, not a decision.
- **Mode-header precedence policy is Profile > Mode.** Any future routing override that sits between "concrete profile name" and "default" must respect this ordering, or the `X-CodeRouter-Mode` docstring must be updated to reflect the new policy explicitly.

The full sub-release detail lives in `CHANGELOG.md` (sections `[v0.6-A]` / `[v0.6-B]` / `[v0.6-C]` / `[v0.6-D]`). This document is the narrative layer on top.
