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.

Scope & doctrine. Wave-0 of the Agentic Interaction Epic (#1646), grounded in RFC-APB-004 (ACCEPTED) + pramaan-docs ADR-116/ADR-120/ADR-129. Additive-first: never rename-in-place — 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

FileRole
src/shared/agent_rails/config/thinking_levels.yamlThe 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.pyNew 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.pyGenerated, 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.tsGenerated. TS enum + order + labels + aliases for the app to consume (separate child wires it).
src/shared/agent_contracts/axes.pyDrops 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.pyWire 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.pyDocstring 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.yamlFloors 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.pyLEVEL_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 0124DB 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 + MakefileNew make reasoning-levels target + a CI freshness-diff gate: regenerate and fail on any diff to the two generated files (LI-6).
TestsNew 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:

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.

Verification (system boundary, not component boundary). Full suite green on a freshly provisioned Docker Postgres: 1488 passed (the one unrelated failure is a known asyncpg connection-race in 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.