Story 1648: one yaml owns the reasoning ladder — the migration that ends migrations
Why this exists (the catastrophe it prevents). The composer's reasoning pill — Low · Medium · High · Max · Ultra Max — is a model tier: it routes a turn to a cheap open model or to a frontier one. But the set of levels was duplicated in five places: a hand-authored Python enum (ThinkingLevel), thinking_levels.yaml, floors.yaml, every agent's model.json, and the app's UI. Apply the §1e litmus — "add the 6th level in 3 months: migration, or config entry?" — and the honest answer was a three-repo, closed-enum migration, again. Worse, the runtime had only four levels (low·medium·high·extra_high) while the UI showed five, so the pill literally could not route, and the top two levels collapsed onto one model — a five-step pill implying five quality steps when only some were real. This Story makes one yaml the single source of the level SET and codegens the Python + TS enums from it, migrates the ladder to the canonical low·medium·high·max·ultra_max with extra_high kept alive as an additive alias, and nails down the rule that there is exactly one place a level becomes a model. After this, the next level change is a yaml edit + regen — never a multi-place migration.
extra_high stays accepted and normalizes to max; alias removal is gated on client rollout (telemetry shows no legacy traffic), not a release number. One repo (pramaan-functions): the TS enum is generated from this yaml; the app consumes it in a separate child. Observability per ADR-124 (the floor log gains one low-cardinality provenance key; the codegen script is build tooling, instrumentation-exempt).🛰️ Satellite: the set lives once; everything else is generated or resolved
thinking_levels.yaml ← THE single source of the level SET
(order = rank · labels · aliases · per-level model+effort)
│
┌──────────────┼───────────────────────────┐
│ make reasoning-levels (codegen) │ resolve_thinking() (runtime)
▼ ▼
_reasoning_levels.py generated/reasoning-levels.ts level → {model, reasoning_effort}
(Python ThinkingLevel (app consumes; separate child) THE ONE level→model resolver
enum, re-exported by (broker owns it; agents map
agent_contracts.axes) level→their OWN tier only)
│ │
└─ CI freshness-diff gate (LI-6): regenerate; fail on any drift ─┘
Canonical ladder: low · medium · high · max · ultra_max
Alias window: extra_high ──normalizes──▶ max (old payloads/sessions still parse)
Distinct model per level (no no-op): cheap-open ─▶ ─▶ ─▶ ─▶ frontier
The key idea: the yaml is authoritative for two things — the set (codegen'd into both languages, so compile-time safety survives) and the resolution (a level → a concrete model + effort, read only by the broker). A model swap or a label change is a yaml edit; a new level is a yaml edit + make reasoning-levels. The closed-enum migration becomes a non-event.
✈️ Airplane: the change surface
| File | Role |
|---|---|
src/shared/agent_rails/config/thinking_levels.yaml | The single source. v2: ordered canonical ladder, per-level label + model + reasoning_effort, and aliases: ["extra_high"] under max. Each level maps to a distinct model (cheap-open → frontier); ultra_max is the frontier tier (global default; per-agent overridable). |
scripts/gen_reasoning_levels.py | New codegen. Reads the yaml, emits the Python enum + the TS enum. Deterministic, fail-closed. Build tooling (ADR-124-exempt). |
src/shared/agent_contracts/_reasoning_levels.py | Generated, do-not-edit. The ThinkingLevel StrEnum + rank table + alias map (_missing_) + labels + THINKING_LEVELS / ACCEPTED_THINKING_LEVELS. Pure (no IO at import) so the brains can import it. |
generated/reasoning-levels.ts | Generated. TS enum + order + labels + aliases for the app to consume (separate child wires it). |
src/shared/agent_contracts/axes.py | Drops the hand-authored enum + rank table; re-exports ThinkingLevel (and the level sets) from the generated module, so every existing import path is unchanged. |
src/shared/agent_contracts/invoke.py | Wire ThinkingLevelLiteral widened to the canonical 5 plus extra_high (alias window), held in lock-step with ACCEPTED_THINKING_LEVELS by the parity test. |
src/shared/agent_rails/thinking.py | Docstring nails the single resolver owner invariant; resolution is unchanged (still reads the yaml, still last in the pipeline). |
src/shared/agent_rails/floors.py + config/floors.yaml | Floors migrate atomically (level terms stay valid). Each floor decision is now provenance-stamped (floor_source: global / agent_extra / global+agent) on the resolution and the floor-firing log. |
src/shared/agent_rails/budget.py | LEVEL_COST_UNITS doubled per step (1·2·4·8·16) so ultra_max carries its own monotonic weight and never collapses to max. |
src/domains/agent_runtime_policy/entity.py + migration 0124 | DB CHECK widened to the canonical set + alias; migration 0124 migrates persisted extra_high rows → max and widens both CHECKs atomically (with a clean downgrade). |
.github/workflows/ci.yml + Makefile | New make reasoning-levels target + a CI freshness-diff gate: regenerate and fail on any diff to the two generated files (LI-6). |
| Tests | New codegen-freshness + resolver-ownership + distinct-model + provenance + legacy-payload backcompat tests; existing EXTRA_HIGH references retargeted to MAX. |
🔬 Microscopic: how extra_high stays alive without splitting truth
Title every line by its reason. The alias is additive, not a rename — and it lives in exactly one place, the generated enum's _missing_, so every coercion of a string into ThinkingLevel normalizes a legacy value to its canonical member for free:
class ThinkingLevel(StrEnum):
LOW = "low"; MEDIUM = "medium"; HIGH = "high"
MAX = "max"; ULTRA_MAX = "ultra_max"
@classmethod
def _missing_(cls, value):
# legacy wire/persisted value → canonical member; anything else → ValueError
canonical = THINKING_LEVEL_ALIASES.get(value) if isinstance(value, str) else None
return cls(canonical) if canonical is not None else None
THINKING_LEVEL_ALIASES = {"extra_high": "max"} # the ONLY alias definition
That single hook does the work at every boundary that coerces through the enum:
- Wire (invoke.v1): a frozen
Literal(not the enum) so the JSON shape stays plain — soextra_highis kept in the acceptedLiteralfor the alias window; the broker then coerces it viaThinkingLevel(...)inrun_posture→MAX. Verified: a legacy{"thinking_level": "extra_high"}invoke still validates. - Policy DTO / app route: the field is the enum, and pydantic v2 honours
_missing_— so a legacyextra_highbudget-preview request parses end-to-end (route → service → DB) and resolves tomax. Proven against a real DB in the integration test, not a mock. - DB: migration 0124 rewrites stored
extra_highrows tomaxso the persisted vocabulary is canonical, while the widened CHECK still acceptsextra_highso a stray legacy write never fail-closes during the window.
Why a separate generated module, not codegen the whole axes.py. axes.py also holds RunMode, AuthorityMode, AuthorizedScope — hand-authored and not part of this migration. Generating only ThinkingLevel into _reasoning_levels.py and re-exporting keeps the blast radius tight and the generated file purely mechanical, while from ...axes import ThinkingLevel keeps working everywhere unchanged.
The single-resolver-owner teeth. RFC-APB-004's rewrite-class failure is functions telemetry saying "opus" while an agent actually called DeepSeek through its own map. test_resolver_ownership encodes the invariant structurally: only thinking_levels.yaml may carry a model: key — floors, capabilities, and per-agent matrices speak levels/tiers/capabilities, never a model id. A second level→model map anywhere in this repo fails the test at review.
test_audit_log_schema, passes in isolation — untouched by this change). Migration 0124 applied to a real DB with a clean upgrade → downgrade → upgrade round-trip and alembic check drift-free. The legacy-extra_high backcompat is proven through a real POST against the runtime-policy route, not a fixture. make openapi regenerated (app-api ThinkingLevel enum now low·medium·high·max·ultra_max); make reasoning-levels freshness gate wired into CI. ruff clean; my changed files type-clean.One-sentence recall: the reasoning ladder now lives in one yaml that codegens both enums and resolves the one level→model map, so the next level change is an edit-and-regen — and the old extra_high keeps working through a single alias hook until the clients have moved on.