# Upgrade Guide — vNEXT

<!-- assembled-by: assemble-next-md -->
<!-- bump: patch -->

## What Changed

Begins Increment B (the autonomous use-it-or-lose-it drainer) with its decision core, deliberately split out so the entire policy is unit-testable before any autonomous write exists.

- **`CredentialRebalancerPolicy.decidePass()`** (src/core/CredentialRebalancerPolicy.ts) — a PURE function computing the zero-or-more credential swaps for one balancer pass from a read-only snapshot (per-account quota + reset proximity, per-slot tenancy/verify/activity, cooldown state, resolved config):
  - **Objective 1 — wall avoidance:** a tenant over the high-water mark (default 85%, either window) gets the highest-headroom eligible account; a tenant over the critical mark (default 95%) triggers a wall-OVERRIDE that bypasses the cooldowns and the 1-swap/pass cap — itself bounded by the fresh-data gate (never re-fires on the same sensor snapshot), `maxForcedSwapsPerPass`, and a per-window override budget whose exhaustion surfaces a degraded + attention terminal state instead of looping. The rescue target must be verified-recent (the recency gate).
  - **Objective 2 — use-it-or-lose-it drain:** an account whose WEEKLY window resets within the horizon with ≥30% unused headroom is dealt to the busiest eligible slot (weekly only — 5h windows regenerate); a per-slot "drain in progress" hold stops the 3-way drain rotation from fragmenting the window.
  - **Eligibility + hysteresis:** needs-reauth/disabled/quarantined/unverified never participate; stale-quota accounts are SOURCE-only; per-pair + per-tenant cooldowns (the tenant cooldown defeats the 3-way rotation attack); a min-improvement floor with urgency clamped at 4h-to-reset; 1 swap per pass for non-forced objectives.
- **Unwired + dark.** Nothing actuates on the decision yet. Dead-default eviction, the correlated-oracle-outage floor, quarantine-exit, the P19 breaker, and the scheduled identity audit are the next Increment-B step <!-- tracked: 20905 -->; wiring the decision through the live executor (under the existing `subscriptionPool.credentialRepointing` dark/dry-run gate), the live `GET /credentials/rebalancer` status, and the routes follow <!-- tracked: 20905 -->.

Adds the highest-priority objective to the balancer decision core: keep `~/.claude` serving a healthy account so manual `claude` invocations stay predictable (§2.4 objective-0).

- **Dead/quarantined-default eviction** — when the default slot's tenant goes needs-reauth/disabled OR the default slot is quarantined, `decidePass()` now deals a healthy, identity-verified-recent tenant into `~/.claude` (a `default-eviction` decision), parking the dead/quarantined credential in the vacated slot with an attention note. This runs before wall-avoidance and drain — a frozen default freezes the operator's manual `claude`.
- **Correlated-oracle-outage floor** — when NO slot is currently oracle-verifiable (an `api.anthropic.com` storm quarantines every probe at once), the policy does NOT empty or churn the default: it preserves the last-known-good assignment, surfaces a degraded + attention entry, and performs no eviction until the oracle returns. Honest bound: the floor's guarantee is "preserve last-known-good + flag", explicitly NOT "manual claude is certified live".
- **Bounded fallback** — verifiable slots exist but none is an eligible healthy tenant → surface, do not act. Two new inputs (`desiredDefaultAccountId`, per-slot `lastKnownGoodAccountId`) + the `default-eviction` objective. Still pure, still unwired; consecutive-forced-eviction P19-capping is the next step <!-- tracked: 20905 -->.

Adds the stateful orchestrator around the Increment-B decision core — the loop that will eventually run the autonomous balancer, built and tested before it is wired to anything.

- **`CredentialRebalancer.tick()`** (src/core/CredentialRebalancer.ts) — builds the read-only pass snapshot from injected providers, calls `decidePass()`, and actuates each accepted swap through the injected executor wrapper, under the feature's dark/dry-run gate:
  - **Dark = strict no-op:** when the feature is disabled the pass returns immediately having called the providers and the executor ZERO times.
  - **Dry-run actuates the decision but not the write:** the executor enforces dry-run (no credential moves); the pass advances cooldown state so a dry-run soak shows realistic anti-churn cadence.
  - **The executor is the only write path:** a swap rejection is caught and treated as a failure (no crash).
  - **P19 breaker:** N consecutive LIVE failed swaps opens it; a success resets it; it self-heals by re-probing on later passes. A dry-run never trips it.
  - **Cross-pass hysteresis:** cooldown timestamps recorded by the tenant-account pair (matching the policy's cooldown check), the per-window forced-override budget incremented only on a successful wall-override and time-reset.
- **Unwired + dark.** The server `setInterval` pass, the live `GET /credentials/rebalancer` status, and a tick-serialization (reentrancy) guard are the next step <!-- tracked: 20905 -->.

Adds the pure translation layer between the live system state and the balancer's decision core, kept out of server.ts so a units/sign bug can't silently mis-steer the balancer.

- **`CredentialRebalancerSnapshot`** (src/core/CredentialRebalancerSnapshot.ts) — pure mappers:
  - `mapAccount`/`mapAccounts`: a SubscriptionPool account → the policy's `AccountState` (5h/weekly utilization %, weekly-reset hours, status). A missing quota reading maps to an epoch `measuredAt` so the account is treated as STALE (source-only) — never dealt in on a reading we don't have. `rate-limited` stays eligible (`ok`) so wall-avoidance can rescue its slot; only `needs-reauth`/`disabled` are ineligible.
  - `mapSlot`/`mapSlots`: a CredentialLocationLedger assignment → the policy's `SlotState` (default-slot flag, quarantine, last-verified time; a quarantined-empty slot normalizes to a null tenant).
  - `resolveRebalancerConfig`: clamp the configured knobs into the resolved config and derive the cooldowns from the poll interval (per-pair 1×, per-tenant 2×) + the stale-quota window (N poll periods).
- Unwired + dark; the server construction + the timer pass + the live status route are the next step <!-- tracked: 20905 -->.

## What to Tell Your User

Nothing changes — this is off-by-default internal logic with no effect yet. It's the "brain" of the eventual automatic account-balancer: given how much quota each of your accounts has left and how close each is to its weekly reset, it works out whether to shuffle one account's login to a different slot to (a) rescue a session about to hit a wall, or (b) burn down a weekly allowance that would otherwise expire unused. Right now it only *decides* — nothing acts on those decisions, so no credential moves. It's built first and on its own precisely so every rule can be tested in isolation before anything is ever wired to actually move a login, and the whole balancer stays switched off until you decide otherwise.

Still nothing changes — off-by-default internal logic. This teaches the (not-yet-active) account-balancer to protect the one login that must always work: your default Claude account. If that account ever needs a re-login or gets quarantined, the balancer's plan is to slide a known-good account into its place so your plain claude command keeps working, and flag the broken one for you. And if the check it relies on (Anthropic's identity endpoint) is down for everything at once, it deliberately does nothing rather than risk making the default worse — preserving the last-known-good and telling you honestly that it can't currently certify it's live. It only decides; nothing acts yet.

Still nothing changes — off-by-default internal logic with no effect yet. This is the loop that will eventually run the automatic account-balancer: every few minutes it would look at your quota picture, decide whether a login should move, and carry it out. It's built and tested now, but deliberately not connected to anything that runs on a timer, so it never fires. When it is connected, it stays switched off until you turn it on, and even then a first dry-run mode lets it show what it would do without moving a single credential. There's also a built-in circuit-breaker: if a move ever fails repeatedly, it stops trying and flags it rather than thrashing.

Still nothing changes — off-by-default internal plumbing. This is the translator that turns your real account quota and login layout into the form the (not-yet-active) balancer's brain understands. It's careful about safety even here: if it doesn't actually have a fresh quota reading for an account, it treats that account as "don't move anything onto it" rather than guessing; and a rate-limited account stays eligible to be rescued rather than being written off. It only translates data — nothing acts.

## Summary of New Capabilities

No new runtime capability — this is the unwired decision core for the autonomous balancer (Increment B), shipped dark. New internal module `CredentialRebalancerPolicy` (pure `decidePass()`): computes the per-pass wall-avoidance / drain / default-preference swap decisions with bounded wall-override, eligibility, and hysteresis. Not wired into any runtime path; no route, no config flag, no credential write path.

No new runtime capability — extends the unwired Increment-B decision core. `decidePass()` gains §2.4 objective-0: dead/quarantined-default eviction (deal a healthy verified tenant into `~/.claude`) and the correlated-oracle-outage floor (preserve last-known-good, never empty the default during an outage). Not wired into any runtime path; no route, no config flag, no credential write path.

No new runtime capability — the unwired autonomous-write orchestrator for the Increment-B balancer, shipped dark. New internal module `CredentialRebalancer` (`tick()` + `status()`): wraps the pure decision core in a pass loop, actuates via the gated executor under the dark/dry-run gate, carries cross-pass cooldown state + a P19 breaker. Not wired into any runtime path; no route, no config flag, no credential write path of its own.

No new runtime capability — pure translation helpers for the Increment-B balancer, shipped dark. New internal module `CredentialRebalancerSnapshot` (mapAccount/mapSlot/resolveRebalancerConfig): the unwired providers the balancer orchestrator will consume. Not wired into any runtime path; no route, no config flag, no credential write path.

## Evidence

- `tests/unit/credential-rebalancer-policy.test.ts` (18) — eligibility (needs-reauth tenant excluded; quarantined slot not a target); wall avoidance (rescue with highest-headroom; no rescue when only target also walls; held behind per-pair cooldown; triggers on the weekly window too); wall-override (bypasses cooldown; fresh-data gate no-re-fire; per-window budget exhaustion → degraded + attention; ≤ maxForcedSwapsPerPass when multiple critical; recency gate rejects a stale-verify target); stale-quota source-only; drain (weekly-only, headroom floor, drain-in-progress hold excluded, busiest-slot destination); zero-actuation reason; 1-swap-per-pass. tsc + full lint clean.

- `tests/unit/credential-rebalancer-policy.test.ts` (+6, 24 total) — deals a healthy verified tenant in when the default tenant is needs-reauth; rescues a quarantined default too; correlated-outage floor preserves last-known-good and does NOT evict; dead default with no healthy tenant surfaces without acting; healthy default is a no-op (normal objectives run); inert when no default account is configured. tsc + full lint clean.

- `tests/unit/credential-rebalancer.test.ts` (7) — dark = strict no-op (zero executor calls); actuates one swap for a walling scenario; dry-run drives the executor (no-op write) and advances cooldowns; the per-pair cooldown holds a re-swap on the next pass; the P19 breaker opens after 3 consecutive live failures and resets on a success; a dry-run ok never trips the breaker; the status surface reports enabled + breaker + last pass. Independent second-pass review CONCUR. tsc + full lint clean.

- `tests/unit/credential-rebalancer-snapshot.test.ts` (10) — status eligibility (rate-limited stays ok; needs-reauth/disabled ineligible); quota mapping incl. weekly-reset hours + missing-reading → stale; slot mapping (default flag, quarantined-empty → null tenant, override fields); config defaults + clamping + cooldown derivation. tsc + full lint clean.
