# Upgrade Guide — v1.0.0 (Provider Portability release)

<!-- bump: major -->
<!-- Valid values: patch, minor, major -->
<!-- patch = bug fixes, refactors, test additions, doc updates -->
<!-- minor = new features, new APIs, new capabilities (backwards-compatible) -->
<!-- major = breaking changes to existing APIs or behavior -->

## v1.0.0 headline (what this release IS)

Instar is no longer locked to one AI provider. Every hot-path session
(interactive, headless, pipe-mode, upgrade-notify, stall-recovery)
routes through a framework-aware launch helper that supports Claude
Code, OpenAI Codex, AND local models via Ollama / LM Studio (through
Codex CLI's `--oss` passthrough). Operators flip a topic between
frameworks via the `/route` slash command. Generic model tiers
(`fast` / `balanced` / `capable`) map to each framework's preferred
concrete model automatically.

Spec 12 Rule 1 (Codex subscription-only path) lands in Phase A:
adapter init emits a warning + telemetry when API-key auth is
detected; full refusal is opt-in via `INSTAR_RULE1_ENFORCE=hard` for
v1.0.0 and default in v1.1.

Local-model adapter is shipped as a passthrough — `codex exec
--oss --local-provider ollama --model llama3.2:latest` works
end-to-end via Codex CLI 0.50.x. Recipe at `docs/local-model-recipe.md`.

Existing Claude-only installs upgrade transparently: framework
defaults to claude-code, no behavior change unless the operator opts
into Codex or local-model routing.

## What Changed

Codex topics now round-trip end-to-end. Four follow-ups on the /route work below closed the live-test gaps that surfaced when the framework was switched on a real Telegram topic.

First, `respawnSessionForTopic` now kills the existing tmux session before spawning a replacement. `SessionManager.spawnInteractiveSession` no-ops when a tmux session with the same name already exists — that's intentional for "reuse the agent that's already up" semantics, but it silently defeats a framework swap: the kill-and-respawn invariant the /route flow assumed wasn't there. Added the kill at the top of `respawnSessionForTopic` (idempotent — no-op when the session is already dead), so every framework swap, recovery respawn, and /restart path now gets a fresh process. Verified live: a `/route claude-code → codex-cli` swap on a topic with an active Claude tmux session now actually replaces it with a Codex one.

Second, the per-topic resume-uuid is now cleared on framework swap inside the /route handler. The UUID was created under one framework's session-id scheme and is meaningless to the other (Claude UUIDs ≠ Codex session ids); without clearing it the new spawn's `--resume <id>` either logged a "starting fresh" warning (Codex) or attempted to attach to a non-existent session (Claude). The route handler now calls `_topicResumeMap.remove(topicId)` between the store update and the respawn.

Third, codex spawns now request `gpt-5.3-codex` explicitly via `--model`. Codex CLI's default is `gpt-5.2-codex`, which OpenAI retired from ChatGPT-subscription auth on 2026-04-14 (Community thread 1378986). Without the flag, a spawn under a subscription account hits "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account" on the first turn. Picked `gpt-5.3-codex` (the coding-specialist tier in `providers/adapters/openai-codex/models.ts`); API-key users can override per session via `~/.codex/config.toml` or `-c model=`.

Fourth, codex spawns now use `--dangerously-bypass-approvals-and-sandbox` instead of the prior `--sandbox workspace-write --ask-for-approval never` pair. Claude's `--dangerously-skip-permissions` is one flag that means BOTH "no approval prompts" AND "no sandbox" — the parity flag on Codex is the bypass, not the flag-pair. The flag-pair left Codex's seatbelt sandbox running, which blocked the agent from reaching localhost (where the relay script ultimately posts) and from writing outside the project (which the relay needs for its outbox), so Codex sessions read AGENTS.md, attempted the relay, and got denied at the syscall level. `codexSandboxMode` callers still get the flag-pair form for the safer profile. Live-verified: Codex now reaches `.claude/scripts/telegram-reply.sh`, calls instar's local server, and round-trips messages back to the Telegram topic.

Fifth, `SessionManager.rawInject` is now framework-aware. Codex's TUI takes longer than Claude's to commit a bracketed paste into its input state, and silently discards a single Enter that arrives during that window — observed live as messages stacking up in Codex's input box without ever submitting. The injector now reads the spawned session's `INSTAR_FRAMEWORK` from its tmux env (cached, single lookup per session), and for `codex-cli` waits 1.5s after the paste end (vs 0.5s for Claude) and sends Enter twice with a 300ms gap. The second Enter is a harmless no-op against an empty buffer if the first one landed. `clearSessionFrameworkCache(name)` is exposed so callers killing-and-respawning a tmux name under a different framework (e.g., the /route flow) can invalidate the cache; `respawnSessionForTopic` calls it.

Sixth (closes the legacy-install gap): `spawnSessionForTopic` now calls `ensureFrameworkIdentityFile` before every spawn — a new idempotent helper in `IdentityRenderer` that ensures the framework's shadow file (AGENTS.md for codex-cli, CLAUDE.md for claude-code) exists at the project root. When `.instar/AGENT.md` exists, it renders from there; when only a legacy shadow exists (older installs that authored CLAUDE.md directly without AGENT.md), it bootstraps AGENT.md from the legacy shadow first, then renders. Idempotent — no-op when the target shadow already exists, so the per-spawn cost is one stat call. Closes the gap that required dropping AGENTS.md by hand for the live test. 6 new unit tests.

/route slash-command landed: users can now flip a Telegram topic between Claude Code and Codex via a conversational command. `/route` or `/route status` shows the current framework; `/route claude-code` or `/route codex-cli` switches and respawns the topic's session via the existing TopicMemory-bootstrapping respawn flow. State persists in `<stateDir>/state/topic-frameworks.json` (atomic writes; separate from the operator-edited `topicFrameworks` in config.json so the two write paths don't race). New `TopicFrameworksStore` tolerates corrupt state files at boot — falls back to config defaults rather than crashing. 9 new store tests + 89 touched-area tests pass. Per Justin's clarified design model: the user-facing choice is Claude Code vs Codex; the SDK-credit-vs-subscription distinction inside "Claude Code" is invisible (Phase 5c cost-router handles it automatically when wired in a follow-up).

Tier 1.A (spawnInteractiveSession framework dispatch + per-topic override) landed: the foundational Telegram-driven session spawn path no longer hardcodes Claude's CLI flags. New `src/core/frameworkSessionLaunch.ts` carries per-framework builders that return `{ argv, envOverrides }`; SessionManager dispatches to the right builder based on a new optional `framework` parameter. New `InstarConfig.topicFrameworks` map (`{ "9984": "claude-code", "9985": "codex-cli" }`) lets operators flip a single topic to a different framework without changing the agent's overall framework. `spawnSessionForTopic` and `respawnSessionForTopic` consult the per-topic map via a `resolveTopicFramework(topicId)` helper. `SessionManagerConfig.frameworkBinaryPaths` populated from detection at load time so the dispatcher picks the right binary. Existing Claude callers behave identically. The Agent SDK spawn mode is deferred — needs a different shape than tmux'd CLIs. 12 new launch-builder tests; 181 touched-area tests still pass.

Tier 2.B+ (SessionWatchdog framework-aware PID resolution) landed: the watchdog's `getClaudePid` resolved the in-pane CLI process by matching `^claude$` / `/claude$` directly, so for Codex installs it returned null and the watchdog silently no-opped on every Codex session. New `getFrameworkPid` walks `listProcessSignals()` (reusing Tier 2.C's framework process signals) and matches both bare-name (`claude`, `codex`) and path-tail forms, then falls back to a multi-needle egrep on the pane's child processes. The 65 existing watchdog tests all pass.

Tier 2.E (subsystem framework-aware intelligence) landed: server-boot smoke test of `INSTAR_FRAMEWORK=codex-cli` revealed that three subsystems were still constructing their own `ClaudeCliIntelligenceProvider` rather than reusing the framework-aware `sharedIntelligence` singleton: RelationshipManager (identity resolution), TopicSummarizer (session-completion summaries), and JobReflector (via `instar reflect`). All three now consult `sharedIntelligence` first and fall back to a Claude provider only when sharedIntelligence couldn't be built. Boot log now reports `Relationships loaded: 0 tracked (LLM-supervised (Codex CLI))` instead of always claiming Claude. No interface changes, no new tests (call-site re-pointer to an already-tested singleton).

Provider-portability v1.0.0 spawn-path closure: every remaining hot-path Claude hardcode shipped. New `buildHeadlessLaunch(framework, options)` companion to the existing `buildInteractiveLaunch` — same shape, same exhaustiveness check, same envOverrides contract. `SessionManager.spawnSession` (the headless `-p` prompt path) now routes through it with a per-call `framework` override and reads its binary from `frameworkBinaryPaths[framework]`. `PipeSessionSpawner`'s three direct `claude -p` callsites are gone: `classifyIntent` and `summarizeThreadHistory` accept an `IntelligenceProvider` (fail-closed to interactive/unavailable when none is wired — safer than the old silent shell-exec failure); `spawn()` uses `buildHeadlessLaunch` + `$(< "$file")` substitution so the prompt stays off the command line (no leak via `ps`). Env scrub now covers both `ANTHROPIC_API_KEY` AND `OPENAI_API_KEY` (Spec 12 Rule 1a extended to the pipe spawn). server.ts wires the pipe spawner with the resolved framework + sharedIntelligence. 11 new pipe-portability tests + buildHeadlessLaunch test coverage; 74/74 unit tests across the touched files green.

Provider-portability v1.0.0 tier-vocabulary unification: cross-framework generic tiers (`fast`|`balanced`|`capable`) are now the canonical model vocabulary. New `resolveModelForFramework(framework, modelOrTier)` helper maps the generic tiers to each framework's preferred concrete model (`fast`→`haiku`/`gpt-5.2`, `balanced`→`sonnet`/`gpt-5.3-codex`, `capable`→`opus`/`gpt-5.4`). Legacy Claude tier names map cleanly to Codex equivalents so unported callsites don't crash; raw model ids pass through verbatim. The Codex builder's hardcoded `'gpt-5.3-codex'` is now a `SessionManagerConfig.frameworkDefaultModels['codex-cli']` lookup — operators can override per agent without code changes. `UpgradeNotifyManager.MODEL_CHAIN` switched from Claude-only `['haiku','sonnet']` to generic `['fast','balanced']`; the spawn callback widened to accept generic tiers. `/sessions/spawn` route accepts a `framework` field and a framework-aware model whitelist that rejects Claude-only names when `framework=codex-cli` (and vice versa). `StallTriageNurse` model normalization is now framework-aware: Codex agents get `gpt-5.3-codex` for the default `balanced` tier instead of `claude-sonnet-4-6`. 5 new tier-resolution tests + 197/197 unit tests across 8 touched files green.

Tier 4.C release blocker (Codex non-git directory fix): the `CodexCliIntelligenceProvider` was silently failing for every Codex-based agent whose state directory wasn't a git checkout. Codex CLI refused to run with `--cd <non-git-dir>` and the resulting error was masked downstream as `taskPattern: "unclassified"` (the classifier's catch-all). Added `--skip-git-repo-check` to the `codex exec` invocation; these reviewer/sentinel/canary calls don't depend on the cwd being a trusted git repo. Surfaced and verified via end-to-end smoke test (`INSTAR_FRAMEWORK=codex-cli node dist/cli.js route "refactor python helper"`) that previously produced `auto-defaulted-unclassified` in 0.8s; now produces `code-refactor-python` / `auto-defaulted-no-topic` in ~4s. 5 new regression tests use a fake codex script to assert the exact spawn-arg shape.

## Evidence

**Codex non-git directory fix** — reproduced and verified.

Before (broken):
```
$ INSTAR_FRAMEWORK=codex-cli node dist/cli.js route "refactor this old python helper to use type hints" --dir /tmp/codex-smoke-2 --json
{
  "framework": "claude-code",
  "model": "opus-4.7",
  "taskPattern": "unclassified",
  "source": "auto-defaulted-unclassified",
  ...
}
# Internal Codex error visible only on direct provider call:
#   Codex CLI error: Command failed: /Users/.../bin/codex exec --model gpt-5.2 --sandbox read-only --cd /tmp/codex-smoke-2/.instar ...
#   Not inside a trusted directory and --skip-git-repo-check was not specified.
```

After:
```
$ INSTAR_FRAMEWORK=codex-cli node dist/cli.js route "refactor this old python helper to use type hints" --dir /tmp/codex-smoke-2 --json
{
  "framework": "claude-code",
  "model": "opus-4.7",
  "taskPattern": "code-refactor-python",
  "source": "auto-defaulted-no-topic",
  ...
}
```

Tier 2.C (OrphanProcessReaper framework awareness) landed: the orphan-process sentinel no longer hardcodes Claude's binary patterns or its `grep -i '[c]laude'` prefilter. New `src/monitoring/frameworkProcessSignals.ts` exposes per-framework `psGrepNeedle`, `binaryPattern`, `nodePattern`, exclusion list, and display name; the reaper builds a single `ps … | egrep …` over every framework's needle and tags each process with the matched framework via `matchProcessSignal`. `FrameworkProcess` is the new canonical type (Claude/Codex/etc.); `ClaudeProcess` lives on as a deprecated alias for one release cycle. External-process Telegram alerts now group counts by framework display name. Orphan Codex processes are now detected and cleaned identically to orphan Claude processes. Also rewrote one orphaned `StallTriageNurse` test that asserted on the Rule-2-removed direct-API fallback; the replacement asserts the actual post-Rule-2 heuristic-fallback contract. 24 new signal tests + 200 existing tests pass.

Tier 2.B (StallTriageNurse framework awareness) landed: the stall-detection sentinel's heuristic pre-filter no longer hardcodes Claude Code's tool-call regex and spinner glyphs. New `src/monitoring/frameworkActivitySignals.ts` exposes per-framework activity signatures (`toolCallOrSpinner`, `escapeToInterrupt`, `runningIndicator`, plus a prompt-signatures line injected into the LLM system prompt). The nurse reads `config.framework` from `StallTriageConfig` (defaulting to claude-code for backwards-compat), and `server.ts` threads the resolved `INSTAR_FRAMEWORK` value into the construction site. Previously the shell-prompt restart heuristic would false-fire on a healthy Codex pane (no Claude tokens → "framework wrapper has exited"); now it correctly recognizes Codex tool tokens. 22 new tests (16 signal-module + 6 framework-aware heuristics).

CoherenceReviewer subclasses and CoherenceGate dropped the unused `apiKey` constructor parameter — dead since the Rule 2 path-constraint lockdown removed the direct-Anthropic-API fallback. Reviewer LLM calls already route exclusively through the IntelligenceProvider; the key was being stored but never read.

CoherenceGate now requires an IntelligenceProvider. When none is wired, the response review pipeline is disabled with a warning instead of attempting a raw API fallback.

Internal-only API change: external code that constructs CoherenceGate directly must drop `apiKey` from the options bag and supply `intelligence`.

The anthropic-interactive-pool adapter now accepts an optional `llmFallback` in its config. The empty-prompt canary (Rule 3 detector for the pool's idle signal) had a tested LLM-fallback contract but no application-layer wiring — that's now plumbed end-to-end. Adapter clients can opt in by passing `buildCanaryLlmFallback(intelligence)`; omitting it preserves deterministic-only behavior.

Phase 5c shipped: `CostAwareRoutingPolicy` and `CostStateTracker` in `src/providers/costAwareRouting.ts`. The policy implements the path-constraints "Routing default" (drain SDK credit pot while above the 10% safety margin, switch to subscription floor when at or below). The tracker emits `CostStateSnapshot` objects with a `isMaterialShift` helper Phase 5b consumes to decide when to re-ask the user. Pure additive infrastructure — not yet wired into the runtime; that wiring lands with Phase 5b implementation. 23 unit tests cover every row of the decision matrix and every material-shift category.

Phase 5b.1 shipped: `PreferenceStore` (sqlite-backed cache of framework+model picks keyed by user × task pattern) and `TriggerGate` (pure-function decision logic implementing Phase 5b's three-trigger rule with priority ordering). Both live under `src/providers/uxConfirm/`. The remaining Phase 5b components — TaskClassifier, TelegramConfirmer, OverrideDetector, and the FrameworkModelRouter composition root — land in subsequent slices.

Phase 5b.2 shipped: `TaskClassifier` and `OverrideDetector` — both fast-tier IntelligenceProvider classifiers under `src/providers/uxConfirm/`. The classifier maps a task prompt to a stable kebab-case slug (the cache key for preferences). The detector spots routing overrides in free-text messages ("use Gemini for this one") via LLM, not regex, per the "intelligence over string matching" rule. Both fail-safe on errors — unclassified slug or no-override outcome — so the UX never silently auto-uses a wrong pick. 33 new unit tests pass (13 classifier + 20 detector, including the 8 phrasing variants the spec required).

Phase 5b.3 shipped: `TelegramConfirmer` — the blocking suggest-and-confirm round-trip. Sends the structured prompt via a thin `ConfirmationTransport` interface (testable without real Telegram), blocks on next reply with timeout, parses replies through four deterministic shorthand paths (`ok|c|👍|no|once|/route reset`) before falling through to the LLM-backed `OverrideDetector` for free-text. Returns a discriminated `ConfirmationResult` (`confirmed | overridden | reset | default-no-reply`). 30 new tests; cumulative uxConfirm coverage now 86 tests.

Phase 5b.4 shipped: `FrameworkModelRouter` — the composition root that ties every Phase 5b component into the full flow. Returns a structured `RouteResult` with eight distinct `source` values covering every outcome (cached-silent, confirmed, confirmed-one-shot, overridden-this-task, overridden-this-pattern, reset-defaulted, auto-defaulted-no-topic, auto-defaulted-no-reply, auto-defaulted-unclassified). Cache writes are scoped strictly to user-confirmed paths. The `CatalogProvider` interface abstracts over Phase 5a artifacts so the router doesn't read markdown directly. 13 router tests; cumulative uxConfirm coverage 99 tests.

Phase 5b.5.c shipped: `instar route <task...>` CLI subcommand — a thin composition root that wires every Phase 5b component (TaskClassifier, OverrideDetector, PreferenceStore, StaticCatalogProvider, CostStateTracker, TelegramConfirmer, FrameworkModelRouter) and runs a single classification. Forces `telegramTopicId: null` so the CLI path is deterministic (no-topic → catalog default). Lets Justin exercise the end-to-end Phase 5b flow against his real IntelligenceProvider (Claude or Codex per `INSTAR_FRAMEWORK`) without standing up the full HTTP/Telegram wiring. AgentServer integration deferred — that wiring touches the massive injected-dependencies constructor and is tracked separately.

Phase 5b.5.b shipped: `TelegramConfirmationTransport` — bridges the push-based `MessagingAdapter.onMessage` contract to the pull-based `ConfirmationTransport.awaitReply` contract Phase 5b.3 requires. Per-topic waiter queue with timeout; supersession (new awaitReply on same topic resolves prior with null); drop-on-no-waiter for stale replies; shutdown resolves all pending. Composable via `MinimalMessagingAdapter` + caller-supplied `topicFromInbound` / `outboundForTopic` — same class drives Slack/iMessage/etc. when those land. 9 new tests against a fake adapter; cumulative uxConfirm coverage 124 tests.

Phase 5b.5.a shipped: `StaticCatalogProvider` — hand-curated CatalogProvider implementation. 20 task-pattern defaults derived from the Phase 5a catalogs (code-generation/refactor/debug → Opus 4.7; web-research → Sonnet 4.6; summarize/draft/shell → Haiku 4.5; etc.) plus per-(framework, model) confidence baselines and a `CATALOG_VERSION` string that drives Phase 5b's re-ask trigger. Hand-curated rather than markdown-parsed: deliberate updates when the catalog moves, not silent drift from regex breakage. 16 new tests; cumulative uxConfirm coverage 115 tests.

Tier 0 release blocker fixed: stripped a hardcoded developer-specific path (`/Users/justin/.asdf/installs/nodejs/22.18.0/bin/codex`) from the Codex adapter default config. Replaced with a new framework-agnostic `detectFrameworkBinary(name)` helper that searches install locations, npm-global, nvm-managed, and PATH for any of eight known framework binaries (claude, codex, gemini, aider, goose, cursor-cli, opencode, plandex). Includes a source-level regression test that fails if any future commit re-introduces the asdf hardcode.

Tier 1.B (multi-provider credentials) landed: new `ProviderCredentialKind` + `ProviderCredential` types, new `SessionManagerConfig.credentials: { [providerId]: ... }` field with legacy `anthropicApiKey` / `anthropicBaseUrl` automatically migrated at load time. New helpers `getProviderCredential(config, providerId)` and `buildProviderEnvFlags(providerId, cred)` give code paths a clean way to ask "do I have a credential for X" and "what env vars do I inject when spawning a subprocess for provider X." SessionManager spawn paths are unchanged in this slice — they still read the legacy field; migration to the helper is a follow-up. 14 tests cover migration, kind detection, baseUrl propagation, lookup fallback, and env-flag building for anthropic/openai/google + unknown-provider safe-no-op.

Tier 1.C (Codex intelligence provider) landed: new `CodexCliIntelligenceProvider` sibling of `ClaudeCliIntelligenceProvider`. Routes evaluate() calls through `codex exec` with tier→model mapping reused from the Codex adapter. Plus a `buildIntelligenceProvider({ framework })` factory that picks the right implementation at startup, and a `frameworkFromEnv()` parser for the new `INSTAR_FRAMEWORK` env var (accepts `claude-code` / `claude` / `codex-cli` / `codex` case-insensitive). Until now `ClaudeCliIntelligenceProvider` was the ONLY implementation, so every reviewer/sentinel/canary ran `claude -p` exclusively — "supports Codex" was a promise we couldn't keep. 10 tests cover framework selection, binary detection fallback, env-var parsing.

Tier 1.D (identity-file rendering) landed: AGENT.md is now the canonical source of truth for an agent's identity. New `IdentityRenderer` module reads `.instar/AGENT.md` and writes framework-specific shadow files (CLAUDE.md for Claude Code, AGENTS.md for Codex, GEMINI.md for Gemini) with an auto-generation banner warning against hand-editing. ProjectMapper lookup priority flipped to AGENT.md first, CLAUDE.md fallback (existing installs keep working). Migration path: `bootstrapAgentMdFromShadow` reads legacy CLAUDE.md content, strips banner, writes canonical AGENT.md. 16 tests cover render, banner content, framework subset, source fallback, throw-on-missing, framework detection, and migration bootstrap.

Tier 2.A (framework-aware boot prerequisite) landed: `Config.load()` no longer throws "Claude CLI not found" unconditionally. New pure helpers `resolveConfiguredFramework()` (picks active framework from config / INSTAR_FRAMEWORK env / default claude-code) and `checkFrameworkPrerequisite()` (validates the configured framework's binary is installed) decouple boot logic from Claude. Codex-cli installs now boot cleanly with only the codex binary present. Error messages are framework-specific with the right install command. New `sessions.framework` config field + `INSTAR_FRAMEWORK` env var (accepts claude-code/claude/codex-cli/codex). 11 tests cover every framework × binary-presence combination.

## What to Tell Your User

- **You can pick which AI I run on per topic now.** Tell me "switch this topic to Codex" or "switch this topic to Claude" and I'll respawn with the other framework. The default stays the same as before — you choose only when you want a change.
- **I can run a local model for sensitive work.** If you have Ollama installed on your machine, tell me "use the local model for this topic" and your prompts never leave your machine. Best for routine work; complex coding still wants a frontier model.
- **No surprises if you upgrade in place.** Your existing agents keep working exactly as they did. The new portability paths only activate when you ask for them.
- **You may see a one-time warning at startup** if your machine has an OpenAI API key set in its environment. That's a heads-up — I'll explain why and how to switch to the subscription path. It's informational only; nothing breaks.

## Summary of New Capabilities

| Capability | How to Use |
|-----------|-----------|
| Per-topic framework swap (Claude vs Codex) | Ask the agent: "switch this topic to Codex" |
| Per-topic local model (Ollama) | Ask the agent: "use the local model for this topic" |
| Cross-framework model tiers (fast, balanced, capable) | Automatic — the right concrete model is picked per framework |
| Codex subscription-path enforcement (Spec 12 Rule 1) | Automatic warning when an API key is present; full refusal opt-in for early adopters |
| Existing-install upgrade safety | Automatic — framework defaults to Claude; no behavior change unless you choose |

## Evidence

<!-- REQUIRED if this release claims to fix a bug. -->
<!-- Unit tests passing is NOT evidence. Provide ONE of: -->
<!--   (a) Reproduction steps + observed before/after on a live system. -->
<!--       Include log excerpts, observed command output, or behavior -->
<!--       description. Make it specific enough that a future reader can -->
<!--       re-run it and see the same thing. -->
<!--   (b) "Not reproducible in dev — [concrete reason]" if the failure -->
<!--       mode truly can't be exercised locally (race conditions, -->
<!--       event-driven paths requiring external signals, etc). -->
<!--                                                                 -->
<!-- If this release doesn't claim a bug fix (pure feature / refactor), -->
<!-- leave this section blank or delete it — it's only enforced when -->
<!-- "What Changed" describes a fix. -->

[Describe reproduction + verified fix, OR "Not reproducible in dev — [concrete reason]"]
