Service self-heal: upgrade transaction + runtime trust model

Status:shipped in PR, v0.101.0
Date:2026-09-04
Branch:jack_20260904_service_self_heal
Type:plan

Goal

After any upgrade (CLI jacked upgrade, tray Update button, Windows helper), the tray must come back on every machine, or the upgrade must roll itself back to the version that was running. A supervised service must never die silently at boot.

Root causes (the class, not the instance)

  1. Trust model too narrow. Group-writable was rejected unconditionally. On macOS %admin ALL=(ALL) ALL is the default sudoers rule, so a directory writable by admin is not a privilege boundary: every member can already become root. Homebrew makes its prefix admin group-writable by design, so every Homebrew-installed Python trips the check. Same for Linux sudo/wheel groups and user-private groups.
  2. Upgrade is not a transaction. All three upgrade flows install the new package, tear down (or wait for) the old service, then start the new one. Nothing proves the new build can provision its service contract before the old one is gone, and nothing rolls back when the new one never becomes ready.
  3. Boot refusal is invisible. A provisioning failure escapes as a traceback into the launchd log. The start-failure breaker never records it, so jacked service status cannot name the reason, and the statusline just says "stale".
flowchart LR
  U[jacked upgrade / tray Update] --> I[install new package]
  I --> P{jacked service preflight
new build provisions contract?} P -- no --> R[rollback: reinstall previous version] --> S[service restart old build] --> N[loud notice + recovery file] P -- yes --> M[jacked install --force] --> T[service restart new build] T --> V{ready within timeout?} V -- no --> R V -- yes --> OK[done]
(see diagram)

File Structure

Tasks

Incident 2 (same day): account switch no longer reached running sessions

Root cause. Commit b6391c7 (2026-09-03) replaced sync_credential_to_all_stores with the credential transaction engine. The engine writes the authority (Keychain) and the mirror file, but nothing in that path called update_claude_config_email, so oauthAccount in ~/.claude.json stayed on the old account. Claude Code holds its token in memory and re-reads the store only when that identity changes.

Proof. A pty-driven interactive claude session: after the 0.94.1 endpoint, /status flipped to the target; after master's activate_account, it stayed on the old account; after master plus the identity write, it flipped. The probe script lives at the scratchpad path cc-probe/tui_probe2.py and is the acceptance test for any future switch-path change.

Fix. TransactionDependencies.identity_publisher, called by the engine after a successful authority write on both the unfenced and the cooperative path; runtime._engine_for wires claude_config_identity_publisher(home), so manual, OAuth, launch and auto-swap contexts all republish the identity. SwitchRequest carries display_name and organization_name. A publish failure keeps the switch, degrades the outcome, and reports existing_sessions = restart_required instead of pretending sessions will follow.

Open policy question for Jack. The same rewrite made auto-swap recommendation-only (_execute_swap refuses background mutation without a certified cooperative engine, which no production build has). Auto-swap has been disabled in this machine's settings since 2026-07-01, so it is not the reported bug, but the feature is silently dead on every install. Restoring it means allowing background Keychain writes again, the behaviour 0.94.1 had.

Incident 3 (2026-09-05 morning): a stale session's token refresh overwrote the chosen account

Root cause. Claude Code refreshes an expiring OAuth token from the account it holds in memory and writes the refreshed payload straight into the shared Keychain item. The payload carries no _jackedAccountId stamp and includes refreshTokenExpiresAt (jacked logged "Credential schema drift" at 07:19:48). Any long-lived session on a different account therefore reverts the authority at its next expiry. Two jacked gaps made it worse: an unstamped authority reads as UNUSABLE so the statusline degrades, and on macOS every switch outcome is observed_target_unfenced, which skipped the residency bookkeeping (active pointer, auto-swap pause).

Fix. jacked/api/authority_guard.py: on every observer pass, an unstamped authority is identified through the OAuth profile endpoint, its rotated tokens are adopted into that account's row (so no refresh lineage is lost, the failure that killed account 7), and the desired default is reasserted through the transaction engine with a new reassert context. Unknown accounts are never overwritten. Manual switches now run the residency bookkeeping for every truthful outcome.

Policy note

Trusting root-equivalent groups is a deliberate relaxation of the 0.100.0 rule "group-writable is always rejected". Rationale: a principal who can already sudo gains nothing from a writable directory. Flagged for Jack to bless in the PR.