# Claudex Invariants

Audience: AI coding agents first. Optimized for activation, not prose. Do NOT prettify.

Canonical contract: `docs/specs/2026-07-22-claudex-design.md`. This file is the regression guard: each rule below was broken at least once and cost a re-debug. Read both before touching `claudex.py`, `claudex_router.py`, or `statusline.py`.

Before editing any of those files, run `git log --oneline -- <file>` and read the commit that last touched the rule you are about to change. A rule here is a Chesterton's Fence with the reason attached.

## R-1 — Hybrid `/model` MUST list six rows

Rows: `claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`. Source: `HYBRID_PICKER_MODELS` (`claudex.py:42`) into `availableModels`.

- NEVER reduce the picker to the Codex three "because the session model is Codex". Anthropic rows work; they route to `api.anthropic.com`.
- NEVER declare the mixed picker impossible. It ships and is verified.

## R-2 — Codex IDs render UNPREFIXED

`/model`, `/status`, statusline MUST show `gpt-5.6-sol`, never `anthropic.gpt-5.6-sol`.

Claude Code filters gateway IDs to `/^(claude|anthropic)/i` **only in its own `/v1/models` fetch** (`XIc()`). The cache-read path (`u5r()`) applies NO filter. Claudex writes the cache itself, so unprefixed Codex IDs survive.

- DO: write raw `gpt-5.6-*` IDs into `<CLAUDE_CONFIG_DIR>/cache/gateway-models.json`.
- DO NOT: prefix IDs with `anthropic.` to satisfy the fetch-path regex. That regex is not on this path; the prefix leaks into every user-visible surface.
- The router still strips a leading `anthropic.` (`claudex_router.py:36`) as inbound tolerance. That is a compatibility path, NOT a license to emit the prefix.

## R-3 — Session model MUST be the LAST catalog entry

Claude Code drops every `gateway-models.json` entry listed after `ANTHROPIC_MODEL`. `gateway_catalog_models()` enforces this by appending `HYBRID_MODEL` last.

- NEVER reorder that list into "nice" alphabetical or alias order. Entries after the session model silently vanish from the picker — the exact symptom "claudex shows me only Sol".

## R-4 — Discovery gate needs all three conditions

`zIc()` enables gateway model discovery only with: `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` + firstParty auth + non-default `ANTHROPIC_BASE_URL`. Hybrid satisfies all three (router base URL). Removing any one empties the picker.

- NEVER set `ANTHROPIC_AUTH_TOKEN` or an `apiKeyHelper` key in hybrid. Either replaces the Anthropic OAuth bearer on real requests and 401s the Anthropic leg. Hybrid strips `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` (`hybrid_claude_env`).
- Claude Code reads the catalog file with NO credential. Only its own discovery fetch needs one — and claudex never lets it fetch.

## R-5 — Launch against the account's authoritative credential store

Set `CLAUDE_SECURESTORAGE_CONFIG_DIR` to `resolve_credentials(account_home).path.parent`:
- APP_MANAGED grant → the account home.
- VENDOR_MANAGED grant → `~/.claude`, because the live file holds this account's grant.

Claude Code resolves its store as `fY() = CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR`, locks `<fY()>/.storage-write`, and polls `<fY()>/.credentials.json` mtime, so pointing at the owner directory gives correct locking and live rotation pickup.

- NEVER copy `.credentials.json` into an account home as a launch fixture. Refresh tokens are single-use: the live file rotates, the copy is revoked, and the account 401s days later. This is the root cause of the 2026-07 multideal 401.
- NEVER symlink it. `sp()` writes `<path>.tmp.<hex>` then **renames** — the rename replaces the symlink and forks the grant.
- `CLAUDE_CODE_HOST_CREDS_FILE` is NOT a store override. It appears only in env-strip lists (`F5n`) and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` deny-paths. Rejected on evidence; do not re-propose.

## R-6 — Routing is by model prefix, and billing follows it

`route_target()` (`claudex_router.py:33`): body `model` starting `gpt-` (after optional `anthropic.` strip) → loopback `claude-code-proxy` (OpenAI/Codex quota). Everything else, including bodyless requests → `api.anthropic.com` (Anthropic quota).

- The router MUST NEVER read, mint, or refresh credentials. It forwards the caller's `Authorization` verbatim. Claude Code owns Anthropic OAuth refresh, so a long session never outlives an injected token.
- Failure is loud on both legs, by design: dead Codex leg → `502 {"type":"error","error":{"type":"api_error","message":"claudex router: upstream ..."}}`; bad Anthropic grant → upstream `401` relayed unchanged. NEVER add a cross-provider retry, silent re-route, or degraded-mode default. A quota error MUST surface as an error.
- There is NO model fallback. `--fallback-model` is opt-in and `--print`-only; claudex never passes it. `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1` is transport-only — do NOT read it as a model fallback.
- Intentional non-picker billing: `ANTHROPIC_SMALL_FAST_MODEL=gpt-5.6-luna` bills background traffic to Codex; subagents inherit the session model because `CLAUDE_CODE_SUBAGENT_MODEL` is stripped (an agent definition naming `opus` bills Anthropic).

## R-7 — Account flags

`--account|--profile SLUG` pins both providers when both exist. `--codex-account|--codex-profile` and `--claude-account|--claude-profile` pin one each and override the shared flag in either order. Space and `=` forms both parse.

- Codex resolves a raw slug under either tray root; Claude routes through `CommandRouter`, so it accepts every token `cld --profile` accepts.
- When `--account|--profile SLUG` is used and the Codex account exists but no Claude account matches that slug, launch GPT-only proxy mode (Sol/Terra/Luna picker only). This is the only account fallback; it is NOT a default-account substitution.
- Unknown Codex account or explicit `--claude-account|--claude-profile SLUG` with no matching Claude account MUST fail closed, exit `1`, naming the account. NEVER fall back to the default when a slug was requested explicitly on a single-provider flag.
- All flags MUST be stripped before `claude` sees argv.
- Resolve tray roots through `_codex_tray_dirs()`, never a module-level constant: tests monkeypatch `Path.home()` after import.

## R-8 — Account locks apply independently to both providers

`account_locks.json` is the shared admission policy (ADR 0004). Direct Codex selection and Claude selection through `CommandRouter` MUST both authorize and revalidate before process creation.

- A locked implicit/default provider MUST fail closed. NEVER let `--human-override-lock` spill from an explicitly pinned provider onto the other implicit provider.
- `--human-override-lock` is wrapper-only, MUST be stripped before `claude` sees argv, and requires exactly one explicit selector per affected provider.
- The one-shot override MUST require the exact `/dev/tty` confirmation, MUST NOT clear the persistent lock, and MUST be revalidated before each proxy/native/seat launch boundary.
- Invalid lock state exits `77`. NEVER convert it into default-account fallback, rate-limit fallback, or an unlocked interpretation.

## R-9 — Statusline pin follows the running process

`pin_session` MUST consult the environment (`SYSTRAY_CODEX_ACCOUNT_SLUG`, `SYSTRAY_CODEX_ACCOUNT_HOME`) BEFORE returning an existing pin, and re-pin when the slug differs.

- NEVER restore the short-circuit "existing pin wins". It made `/resume` show the account the conversation started on forever.
- `render()` MUST NOT re-pin. Pins change at SessionStart only.
- Env-driven by design: resuming under bare `claude` keeps the old pin, because nothing names the account.

## Verification before claiming done

1. `uvx --from pytest pytest -q` — full suite green.
2. `uvx --with types-requests mypy claudex.py` — clean.
3. `uvx ruff check .` — at the documented pre-existing baseline (`claudex.py` 1× I001; `statusline.py` + `tests/test_statusline.py` 5). Confirm any delta is yours via `git stash`.
4. Live, both legs: `claudex -p 'say PING only'` (Codex) and `claudex --model opus -p 'say PONG only'` (Anthropic). A green suite does NOT prove the credential path; only a live call does.
5. Commit with explicit paths. NEVER `git add -A` in this repo — `runstate/`, `.superpowers/`, and `docs/plans/` are untracked working state and will be swept in.
