# Upgrade Guide — vNEXT

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

## What Changed

Step 4b of the live-credential-repointing build (Subscription & Auth Standard), shipping **dark**. Every in-process write to the `Claude Code-credentials` keychain store now routes through the Step-4a `CredentialWriteFunnel`'s per-slot lock, so two writers to the same config home serialize instead of interleaving:

- `OAuthRefresher.refreshClaudeToken` wraps its token write in `withSlotLock(credentialSlotKey(configHome), …)`. A lock-acquire timeout returns a new typed reason `write-skipped` — the OAuth exchange already succeeded and the existing valid credential is left untouched.
- `QuotaPoller.pollAccount` maps `write-skipped` to "no snapshot this cycle, retry next tick" — explicitly NOT `needs-reauth`. A momentarily-busy lock can no longer cry-wolf a healthy login into a re-auth prompt.
- `CredentialProvider` adds `writeCredentialsSerialized`, the one sanctioned chokepoint that serializes a provider write; `AccountSwitcher` now uses it (a busy lock returns a non-destructive "store busy, try again").
- A new lint, `lint-no-unfunneled-credential-write.js` (wired into `npm run lint`), forbids any future unfunneled write to the Claude credential store — the structural guarantee that no new code path can bypass the lock. It is file-scoped to the guarded service, so the other keychain vaults are never false-flagged.
- `credentialSlotKey` canonicalizes the lock key so a refresh and a switch on the same home always share one lock regardless of path spelling.

The credential-repointing feature gate is untouched and remains off + dry-run. With no swap running (always, while dark), the funnel adds only per-slot ordering and a bounded skip-retry under contention — behaviorally identical to before.

The session reaper's shared guard (`ReapGuard`) had two halves that disagreed about a *stale* open commitment. The KILL decision (`evaluate()`) already treats a commitment as abandoned after 8h of topic silence and lets the idle session be reaped. But the RESUME-eligibility decision (`workEvidence()`) counted **any** open commitment as proof of interrupted work — with no staleness gate. So an idle session was killed (commitment stale ⇒ reap) and immediately revived (commitment exists ⇒ resume-eligible), in an endless loop.

This patch applies the **same 8h staleness gate** to the resume-eligibility probe that the kill decision already uses, so the two halves agree: a stale commitment neither keeps a session alive nor revives it, and a fresh one keeps the session alive so it never needs reviving. Strictly safer — it can only ever revive *less*, never more. Genuine interrupted work (a live build, an active sub-agent, a pending injection, a running process) is untouched.

When a topic running on the escalated (ultra Fable) tier is moved between my machines via `POST /pool/transfer`, the live escalation no longer silently drops on the resumed session.

- **Source capture (`/pool/transfer`)** — when the moving topic has a LIVE session on this machine running on an escalated model id, and the topic is not pinned `escalationOverride:'suppress'`, the source files an ephemeral `EscalationHint` keyed by topic. A non-escalated / suppressed / default-tier topic files nothing.
- **The carry (existing acquire pull)** — the hint rides the EXISTING authenticated `topic-profile-pull` acquire pull (no new verb, no broadcast, N-machine-safe). The serve handler PEEKS the source hint; the destination's apply-landing — AFTER the mandatory ownership recheck — drives the re-admit for the now-owned topic. The same-machine (target==self) path consumes the local hint at the resumed-session spawn.
- **Destination re-admit (the safety invariant)** — re-admission is literally `ModelSwapService.swap(name,'escalated')`, which calls the destination's own `EscalationGovernor.admit()` — every cost guard intact (quota headroom, per-account concurrent-escalation cap, hourly budget, daily ultra-token cap, TTL, dwell/hysteresis). Admitted → swapped; refused (any guard) → left default; `suppress` consult → never escalated. The hint is a TRIGGER carry, never a tier grant: there is no second admit path, so escalation can never be smuggled across.
- **Ephemeral, not a pin** — the hint lives in its own TTL-bounded (6h), consume-once file (`state/model-tier-escalation/rides-topic-hints.json`), never resolved into the durable topic profile.
- **Dark** — the whole path is behind `models.tierEscalation.ridesTopic` (default false) under the existing `tierEscalation.enabled`. With escalation off (the fleet default), it is a strict no-op. Single-machine installs are a no-op.
- **Awareness** — an "Escalation rides a moved topic" bullet extends the existing Model-Tier Escalation section in both `generateClaudeMd` (new agents) and an idempotent `migrateClaudeMd` additive-bullet patcher (existing agents).

A precise per-trigger hint label (carrying the actual originating trigger rather than the generic transfer audit label) is DEFERRED as a tracked follow-up (CMT-1416): the live trigger that escalated a session is not durably recorded per-session today, and the destination governor re-evaluates from real state regardless, so the precise label is audit-nicety, not load-bearing.

## What to Tell Your User

Nothing changes for you right now — this is internal plumbing for the upcoming restartless subscription rebalancing, and it is shipping switched off. When that feature is eventually turned on, it will be able to move an account's login between config slots without ever interrupting a session; this step lays the safety groundwork so two background credential operations can never step on each other and accidentally knock a healthy login into a "please log in again" state. You don't need to do anything, and you won't see any difference until the feature is explicitly enabled.

Sessions that finished their work but still had an old, untouched promise on the books were being killed and revived over and over. That loop is fixed. You'll see fewer "🪦 your session was shut down — a restart is queued" notices on topics where nothing was actually unfinished. Promises still get followed up on by the commitment system — that part is unchanged.

When you run me on more than one machine and move a heavy-work conversation from one to another, the bigger model it was using no longer silently disappears the moment the conversation resumes on the new machine. The new machine asks itself whether that conversation should be on the bigger model and re-runs its own cost checks first — quota, budget, anti-flapping, how many big-model sessions it is already running. Only if all of those pass does it put the resumed conversation back on the bigger model. If they do not, or if you pinned that topic to never escalate, it simply runs on the normal model. So the bigger-model state follows your moved conversation, but it is always re-priced against the machine it lands on, never smuggled across to dodge a budget. This ships off by default and does nothing at all on a single machine.

## Summary of New Capabilities

| Capability | How to Use |
|-----------|-----------|
| Serialized credential writes (per-slot lock) | Automatic (internal) — every Claude credential write is now ordered through one funnel |
| Busy-lock safety on token refresh | Automatic — a contended refresh retries next cycle instead of triggering needs-reauth |
| Unfunneled-write lint guard | Automatic at build time (`npm run lint`) |

No new capability — this is a bugfix. It removes a spurious session kill→revive loop driven by stale (long-untouched) open commitments by aligning the reaper guard's resume-eligibility decision with its existing kill-decision staleness rule.

`models.tierEscalation.ridesTopic` (default false) — when on (under `tierEscalation.enabled`), a topic moved via `POST /pool/transfer` while escalated carries its escalation trigger as an ephemeral hint, and the destination re-admits the resumed session through its own EscalationGovernor cost guards. New exported `EscalationHintStore` (the ephemeral per-topic hint carrier). No new HTTP route; rides the existing `/pool/transfer` + `topic-profile-pull` surfaces.

## Evidence

- 15 new unit tests (`credential-write-routing.test.ts`, `lint-no-unfunneled-credential-write.test.ts`) plus all 193 pre-existing credential/oauth/quota/account-switcher tests green (208 total). The routing tests prove end-to-end that a busy lock yields `write-skipped` → no-snapshot and does NOT mark `needs-reauth`, with a CONTRAST test proving a genuine `exchange-failed` still does.
- `npx tsc --noEmit` clean; full `npm run lint` chain clean (including the new lint and the destructive-tool lint with the new script allowlisted).
- Independent second-pass reviewer subagent: **CONCUR** — traced the token-refresh hot path, confirmed no regression, no lock-wedge, no double-lock, and a sound lint on both axes; the one non-blocking concern (lock-key string fragility) was fixed inline via `credentialSlotKey`.

- Live `logs/reap-log.jsonl` (2026-06-13): **13** age-limit reaps with `midWork=true`, every one carrying solely `workEvidence=[open-commitment]`, across 6 topics — and corresponding repeated `reason=age-limit` respawn entries in the resume queue (several doubled per topic). The loop, captured.
- Root cause confirmed in source: `ReapGuard.evaluate()` gates the open-commitment KEEP on `recentUserMessage(topicId, staleCommitmentWindowMs)`; `ReapGuard.workEvidence()` did not.
- New regression tests in `tests/unit/work-evidence.test.ts`: a stale commitment emits no `open-commitment` evidence (and `isMidWork`→false); a fresh one still does; an explicit `evaluate()`/`workEvidence()` consistency assertion; and the `protectOpenCommitments:false` boundary. All 22 work-evidence + 19 reap-guard unit tests green; 164 related reap/resume tests green; `tsc --noEmit` clean.
- Independent second-pass review (lifecycle change): **Concur** — confirmed the age-limit path uses the patched fallback, no legitimate revival is lost, downstream ResumeQueue / live-enqueue / boot-reconciliation re-enqueue (all `midWork`/`evidenceEligible`-gated) all behave correctly.

- `tests/unit/escalation-hint-store.test.ts` — the EscalationHintStore lifecycle (file / peek-without-consume / consume-once / TTL expiry / suppress-clear / durable restart round-trip / corrupt-file = no-hint = safe / all() prune); the topic-profile pull serve-handler peek carry (the hint rides the existing pull, independent of the durable profile present/absent branch; omitted when unwired = back-compat); and THE NAMED SAFETY INVARIANT driven through ModelSwapService against a synthetic governor — a REFUSING governor yields default tier (never a bypass, the guard is always consulted), an ADMITTING governor swaps, a suppress consult refuses, an already-on-tier re-run is a no-op. Green.
- `tests/integration/escalation-rides-topic.test.ts` — the cross-machine carry + re-admit end to end against a mock governor: a transfer payload carrying the hint + governor ALLOW → swapped; the SAME payload + governor REFUSE → default (the bypass invariant); a hint landing while the topic is owned ELSEWHERE never fires the re-admit (ownership recheck); no source hint → no re-admit. Green.
- `tests/e2e/escalation-rides-topic-lifecycle.test.ts` — Phase-1 "feature is alive" on the production AgentServer init path: the EscalationHintStore is constructed and exposed via `getEscalationHintStore()`; with `tierEscalation.enabled` false the store is alive but inert (file/peek never throw, empty by default); `POST /pool/transfer` is alive and returns the honest dark 503 on a single-machine install (never 404/500). Green.
