# Side-Effects Review — WS5.2 Step 5: CredentialSwapExecutor (staged-exchange swap primitive)

**Spec:** docs/specs/live-credential-repointing-rebalancer.md §2.3 (converged 5 rounds 50→0, `approved:true` — operator pre-approval, Justin topic 13481). **Parent:** Cross-Machine Coherence — One Agent, Robust Under Degraded Conditions.
**Ships DARK** behind the EXISTING `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`, a `DARK_GATE_EXCLUSIONS` `destructive` entry shipped by Step 4b). Going live needs a deliberate two-flag flip. No new config flag, no new dark-gate line.
**Files:** src/core/CredentialSwapExecutor.ts (new), scripts/lint-no-unfunneled-credential-write.js (allowlist + header), tests/unit/lint-no-unfunneled-credential-write.test.ts (allowlist ratchet), tests/unit/credential-swap-executor.test.ts (new), tests/integration/credential-swap-executor-host.test.ts (new), tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts (new), site/src/content/docs/architecture/under-the-hood.md (awareness).

## What changed

1. **CredentialSwapExecutor.ts (new):** the §2.3 staged-exchange primitive. `swap(slotA, slotB)` EXCHANGES (never copies) the two slots' OAuth credentials so the sessions reading them switch token sources on their next API call (E3) with zero restart. The full §2.3 sequence: preconditions (EXACT ledger membership BEFORE any path expansion → traversal can't reach a keychain service) → §2.3.1a source-slot CAS re-read (adopt a same-tenant newer blob, ABORT a different-tenant clobber-race) → staging escrow COPY into a disjoint `instar-credential-swap-staging-*` namespace + journal `begin` → exchange (keychain-first, config-second) → verify on ACCOUNT IDENTITY via the injected oracle resolver (match→commit, mismatch→one repair-from-staging→re-verify, **unavailable→QUARANTINE-NEVER-REPAIR**) → commit with staging RETAINED → delayed ~90s re-verify → delete staging + journal `done`. Consumes the merged primitives: `CredentialWriteFunnel.withSingleMover`+`withSlotLocks`, `CredentialLocationLedger` (membership/journal/quarantine/recordAssignment), and an injected `ResolveSlotIdentity` (composed by the host from `CredentialIdentityOracle`+pool).
2. **Async keychain exec surface (`KeychainCredentialExec` + `defaultKeychainExec`):** ALL `security` calls go through async `execFile` + a 10s timeout — NEVER `execFileSync` (a locked-keychain ACL prompt would wedge the event loop). Injectable so tests run zero-keychain. The default impl derives the slot service via the imported `claudeCredentialService(slot)`; the literal `'Claude Code-credentials'` never appears in the executor source.
3. **Boot recovery (`recover()`):** acquires the single-mover mutex (a recovery WRITE must not race a boot balancer pass on the ledger), resolves every in-flight journal row by re-reading live identity (adopt-on-newer, never a blind staging overwrite), and lifts a **recovery-complete barrier** so the balancer's first pass can run. The barrier carries a bounded HANG-TIMEOUT: a wedged recovery write quarantines its slots BEFORE the lift, so the post-lift balancer structurally cannot select a wedged slot. Recovery completion is INDEPENDENT of dryRun (an already-begun exchange is finished for crash-safety even on the dark config).
4. **`sweepOrphanStaging()`:** the §2.3 step-2 orphan predicate — a staging entry is protected by ANY non-`done` swap journal row (begin/exchanged/verified all keep their staging as the step-6 heal source); only a `done` row (or no row) makes staging an orphan. Avoids both the lost-source bug (deleting a committed row's heal source) and the orphan-leak.
5. **Single audit + scrub chokepoint:** every audited step routes through one `audit()` funnel that scrubs token material (`sk-ant-…` runs → `redactToken`) before it reaches any persisted/served/notified surface (§2.9). Quarantines raise ONE HIGH attention item (the blast-radius surface).
6. **lint allowlist (Step 4b ratchet):** `src/core/CredentialSwapExecutor.ts` added to the `lint-no-unfunneled-credential-write.js` closed allowlist (named in the header as the sanctioned Step-5 funnel route — its raw `add-generic-password` keychain write runs INSIDE `funnel.withSingleMover → withSlotLocks`, so the funnel-routing is at the call layer; the primitive must not self-lock under the slot locks). The self-test that asserts the closed allowlist set was updated to include the 5th entry.
7. **Awareness:** a `CredentialSwapExecutor` subsection in `architecture/under-the-hood.md` (Core Infrastructure) — 2 mentions, satisfies docs-coverage for the new exported class.

## Blast radius

- **The path is config-gated to a strict no-op while dark.** `swap()` returns `disabled` (zero keychain writes, zero ledger mutation) the instant `enabled` is false — the fleet + dev default. `dryRun:true` runs the full decision loop (preconditions + CAS) and audits the WOULD-swap but performs ZERO writes (returns `dry-run` before the staging step). Proven by the E2E dark-ship test (DEV and fleet config) + the unit dark/dryRun tests + the dark-gate test (16/16, unchanged).
- **No new flag, no new dark-gate line.** The executor reuses the Step-4b `subscriptionPool.credentialRepointing` flag already in `DARK_GATE_EXCLUSIONS`/`ConfigDefaults`, so the cartographer dark-gate line-map did not shift (no recompute needed; `lint-dev-agent-dark-gate.js` clean, the dark-gate test green as-is).
- **No unverified live commit.** A slot the oracle cannot identity-confirm (mismatch-unrepairable OR oracle-unavailable) is quarantined + surfaced; `swap()` returns `quarantined` BEFORE `recordAssignment` — the ledger never records a clean assignment for an unverified slot. Proven by THE blocker lens test.
- **No unfunneled credential write.** Every keychain credential write is inside `withSingleMover → withSlotLocks` (the Step-4b funnel guards a swap against a concurrent refresh/switch on the same slot). `lint-no-unfunneled-credential-write` clean.
- **Per-machine local, no cross-machine egress (Phase C).** The swap moves a credential between config slots on ONE machine; it carries no LAN/broadcast assumption and adds no cross-machine traffic. An N-machine pool runs one single-mover mutex + recovery barrier PER machine; each machine's executor is independent and bounded — the fleet never has two movers on the same home.

## Risk + mitigation

- **Risk:** a concurrent Claude-client keychain write on the source slot strands a rotated lineage (the §0.c residual instar cannot lock). **Mitigation:** §2.3.1a source-slot CAS re-reads each slot immediately before the destructive write; a same-tenant newer blob is ADOPTED (carry the client's rotated copy), a different-tenant blob ABORTS the swap (clobber-race), an unconfirmable changed blob ABORTS (never overwrite what we can't identity-verify). The window is narrowed (final-re-read→write), not closed against an external writer — the §2.3.6 delayed re-verify + the §2.4 audit catch the honest residual. Proven by the clobber-race adopt/abort tests.
- **Risk:** a crash mid-exchange destroys a blob or strands staging. **Mitigation:** staging is a COPY (slot A untouched until step 3 → crash-before-step-3 unwinds to a no-op); `begin` is journaled before the first destructive write; staging is RETAINED through step 6 (the heal source); recovery resolves every in-flight row by live identity (adopt-on-newer, never blind). Proven by the crash-at-every-boundary tests (budgets 0/1/2) + the orphan-sweep predicate test.
- **Risk:** an oracle outage during verify triggers a destructive repair-storm against healthy blobs. **Mitigation:** oracle-UNAVAILABLE is read as "unverified", NEVER as "mismatch" — it quarantines (never repairs); repair is reserved for a CONFIRMED mismatch with a reachable oracle. Proven by the quarantine-never-repair test + the host-wired REAL-oracle 5xx test.
- **Risk:** a wedged recovery write (keychain ACL prompt) freezes the balancer forever. **Mitigation:** the recovery barrier carries a bounded hang-timeout that quarantines the unresolved slots THEN lifts (the per-write 10s execFile timeout is what releases the held lock). Proven by the hang-timeout barrier test.
- **Risk:** a token byte leaks into a log/attention/reason string. **Mitigation:** single `audit()`/scrub chokepoint redacts every `sk-ant-…` run before any surface; quarantine reasons + oracle error strings pass through `scrub()`. Proven by the token-scrub test (an adversarial token-bearing oracle reason is redacted).
- **Risk:** two swaps interleave and corrupt a slot. **Mitigation:** the single-mover mutex admits exactly one swap; the loser is a transient `skipped` (retry), never a partial write. Proven by the permutation-property tests (2 and 5 concurrent swaps → exactly one exchange).

## Migration parity

- **No migration needed.** No new config flag (reuses the Step-4b `credentialRepointing` block already in `ConfigDefaults` + `DARK_GATE_EXCLUSIONS`), no new hook, no CLAUDE.md template section (this ships dark + internal; the awareness note lives in the docs site, not the agent template — so no `templates.ts`/`migrateClaudeMd` touch, no `featureSections`/shadow-marker entry). The executor is a new module consumed by the Step-6/7 host wiring (a later increment); nothing on the production path constructs it live yet.

## Dark-gate line-map

- UNCHANGED. No new `enabled:` literal was added to `ConfigDefaults.ts` — the executor is gated by the existing `subscriptionPool.credentialRepointing.enabled` line (shipped by Step 4b). The dark-gate attributor reads `ConfigDefaults.ts` `enabled:` lines only; none shifted. Verified: `node scripts/lint-dev-agent-dark-gate.js` → clean; `tests/unit/credential-repointing-dark-gate.test.ts` → green; the dark-gate ground-truth test → unchanged.

## Rollback

- The feature is already dark (`enabled:false` + `dryRun:true`) — the executor is inert. To fully revert Step 5: remove `src/core/CredentialSwapExecutor.ts` + its three test files, revert the lint allowlist entry + its self-test edit, and drop the `under-the-hood.md` subsection. Dark + additive throughout; no consumer on the production path depends on it yet.

## Tests

- `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, §0.d one-lineage-per-home, staging retained→step-6 delete); **clobber-race** (different-tenant→abort, same-tenant-newer→adopt); **identity verify/repair/quarantine** (mismatch→one-repair→commit; oracle-unavailable→quarantine-never-repair); **THE blocker lens** (no committed-unverified write); **permutation-property** (2 + 5 concurrent swaps serialize, exactly one exchange); **crash-at-every-boundary** (budgets 0/1/2 + hang-timeout barrier with pre-lift quarantine); orphan-staging sweep predicate; token-scrub.
- `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed exactly as the Step-7 host will (REAL `CredentialIdentityOracle` profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW → commit; oracle UNAVAILABLE (5xx) at verify → QUARANTINE + attention.
- `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the PRODUCTION config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op (zero writes, ledger untouched, byte-identical blobs); recovery on the dark config is still crash-safe (an already-begun exchange finishes regardless of dryRun).
- Full unit suite green (zero regressions); `npm run lint` clean (tsc + all structural lints).

## Phase C answer (operator clarity)

The swap is **per-machine local** — it moves a credential between config slots on ONE machine and adds no cross-machine egress / no LAN/broadcast assumption. An N-machine pool runs its single-mover mutex + recovery barrier independently per machine, so the fleet never has two movers on the same home; each machine's executor is bounded by its own mutex.

## 4-lens adversarial verdict

- **Unverified-write bypass (THE blocker):** PASS — no path commits a slot the oracle cannot identity-confirm; an unconfirmable slot is quarantined and `swap()` returns before `recordAssignment`. Test: "no committed-unverified write".
- **Clobber-race / source-slot CAS:** PASS — a concurrent client write is adopted (same tenant) or aborts (different tenant / unconfirmable), never silently overwritten. Tests: clobber-race adopt/abort.
- **Crash-boundary coherence:** PASS — journal begin/done + retained staging + adopt-on-newer recovery leave no lost-lineage state. Tests: crash budgets 0/1/2 + hang-timeout barrier.
- **Dark-ship inertness:** PASS — flag off → strict no-op; reuses the existing dark-gate exclusion (no line shift). Tests: E2E dark-ship (dev+fleet) + dark-gate.

<!-- tracked: 20905 -->
