Publish the full Claude identity on every switch, and keep it true

Status:revision 2 (after plan review + pre-mortem)
Date:2026-10-10
Branch:jack_20261010_full_identity_publish
Type:spec + plan

Goal (user-facing)

After any jacked account switch, on macOS, Linux and Windows, ~/.claude.json oauthAccount is the COMPLETE profile block of exactly the account whose token sits in the credential store (the same block Claude Code itself would write after a profile fetch), and it stays that way. Running sessions follow the switch; /status, the usage cache, Fable/overage consent, /extra-usage gating and synced skills all key on the right account; a stale Claude Code process can no longer write the previous account back in; and jacked never "repairs" the config toward an account it has not proven owns the stored token.

Root cause (verified twice, 2026-10-10). credential_helpers.py:197 _apply_claude_account writes email, displayName, org uuid and org name, never accountUuid, and keeps every other field of the previous account. jacked stores no account uuid. Claude Code 2.1.296's bootstrap merge q() accepts a write only when the bootstrap account_uuid equals the config accountUuid, so after A→B a process holding A's token writes A's email/org back and B's processes cannot correct it. Nothing in jacked re-reads the config after a switch.

Claude Code 2.1.296 facts this plan relies on (extracted from the binary, read-only)

Acceptance criteria

  1. AC1 store identity from the source. Migration adds accounts.account_uuid TEXT, accounts.profile_json TEXT (non-secret subset: the account and organization objects only), accounts.profile_fetched_at REAL, placed after the explicit-column org rebuild and also in CREATE TABLE, the update whitelist, the model and create_account. _update_profile_metadata applies NOTHING (uuid, profile, tier, org name, subscription) unless account.email casefold-equals the row email AND (row org empty OR organization.uuid equals row org); a mismatch logs a values-free WARNING naming the row id. Token exchange (add, re-auth, CC flow, org-redirect branch) stores account.uuid on the row it actually updates. A one-shot paced startup backfill fetches the profile (no token refresh on 401) for every provider='claude' row missing profile_json, and logs one coverage line (rows with / without uuid).
  2. AC2 one publisher, row-based. claude_config_identity_publisher(home, db) loads the row by request.account_id (cross-checks email casefold) and builds the block with claude_identity_for_account(row). Every engine path (dashboard, OAuth, launch global activation, authority_guard reassert/restamp, auto-swap, recovery) gets it for free. launch._seed_oauth_account and sync_credential_to_all_stores use the same builder. The builder refuses non-claude providers and omits a uuid that is stored on rows with different emails.
  3. AC3 complete block. With a stored profile, the builder emits the full NQt field set mapped exactly as above, plus organizationName from the row and tier fields when known; profileFetchedAt = jacked's real fetch time. Without one, it emits the minimal identity (uuid when known, email, org uuid, org name) and the publish reports partial. The writer MERGES (keeps Claude Code's extra fields such as roles) only when the existing block has the same non-empty accountUuid AND same email (casefold, stripped) AND same normalized org (""/None/missing = None); otherwise it REPLACES oauthAccount. Non-identity top-level keys are never touched. The credential payload always carries non-null subscriptionType/rateLimitTier when the row knows them.
  4. AC4 safe config writes. Every identity write: takes a proper-lockfile-compatible <config>.lock mkdir lock (no files inside; stale after 10 s by mtime; bounded retry, longer for strict callers); reads the config under the lock and FAILS CLOSED when an existing file is not a JSON object (no "creating fresh"; only an absent file starts empty); optionally verifies an expected prior oauthAccount (compare-and-swap) and aborts on change; writes atomically.
  5. AC5 watcher. In refresh_resolver_snapshot: only when the service has no CLAUDE_CONFIG_DIR, scope is global, state RESOLVED, an observed Claude row exists, and the config holds an oauthAccount. Divergent when email (casefold) differs, or org differs (compared only when the row org is non-empty), or the row uuid is known and the config uuid differs, or the stored profile's created_at is known and the config accountCreatedAt differs (proof another person's profile was merged). Before writing it (a) try-acquires the switch lease non-blocking and re-resolves under it (held: skip, evidence deferred-switch); (b) proves the stored token belongs to the observed row with a profile lookup cached by authority payload digest (mismatch: no write, WARNING, evidence authority:stamp-mismatch); (c) writes with compare-and-swap. Rate: at most one attempt per 60 s per account, stamped at attempt; breaker trips after 3 republishes in 15 min for the same account (evidence flapping, one WARNING) and resets when the authority digest or desired account changes. Evidence prefix claude-config:identity: plus republished/divergent/partial/uuid-unknown. Logs carry row ids, 8-char uuid prefixes and diverged field NAMES, never emails or tokens; skip reasons log on state transition only. A watcher error never blocks the snapshot publish.
  6. AC6 proof. Unit tests per AC (fixtures use the captured real key shapes), the full local suite once, ruff. Live on the Mac with the branch installed: uuid/profile backfill coverage line; the stale accountUuid block is replaced by a complete block within one observer pass; a same-email two-org round-trip (accounts 2 and 7 here) and a cross-person round-trip each leave a complete correct block; pty probe /status in a session started before the switch shows the target. Windows (mlpc) proof runs once Jack installs the build there (his machine, writes need his go).

Non-goals

Architecture

flowchart LR
  P[profile GET / token exchange / startup backfill] -->|email+org checked| DB[(account_uuid, profile_json, profile_fetched_at)]
  DB --> B[claude_identity_for_account]
  S[engine publisher: switch, oauth, launch, guard, auto-swap, recovery] --> B
  L[launch._seed_oauth_account] --> B
  W[observer 10s: divergence -> lease -> token-owner proof -> rate/breaker] --> B
  B --> A[locked fail-closed CAS write: merge iff uuid+email+org equal, else replace]
  A --> C[~/.claude.json oauthAccount]

Files

FileChange
jacked/web/database.py3 columns (migration after org rebuild, CREATE TABLE, whitelist, model, create_account); helper listing claude rows missing profile.
jacked/web/auth.pyWhole-update email/org guard in _update_profile_metadata; store uuid/profile/time; fetch_profile(..., allow_refresh=True); startup backfill function.
jacked/web/oauth.pyToken-exchange account_uuid; persist on add / re-auth / CC flow / org redirect (actual_target_id); fix account.email_address read on the profile at ~1010.
jacked/api/claude_identity.py (new)Builder (NQt mapping), merge-or-replace, config lock, fail-closed CAS writer, read_claude_config_identity. Keeps credential_helpers.py from growing.
jacked/api/credential_helpers.pyupdate_claude_config_email/_update_claude_config_account delegate to the new writer; fail-open read removed; sync path uses the builder; stale module docstring fixed.
jacked/credentials/runtime.pyPublisher takes db, builds from the row.
jacked/launch.py_seed_oauth_account via the builder.
jacked/api/session_observer.py + jacked/api/identity_watcher.py (new)Watcher (AC5) in its own module; observer calls it.
jacked/api/main.py (or the lifespan that starts loops)Schedule the one-shot backfill.
teststest_claude_identity.py, test_identity_watcher.py, profile-guard and migration tests, updates to runtime/observer/launch tests.

Revision 3 amendments (cycle-2 review; these override AC3-AC5 where they conflict)

  1. Owner proof (AC5b). Uses authority_guard._default_profile_lookup(access_token) (sync GET, no refresh, no DB write) with the authority payload's own accessToken. Never fetch_profile, never a refresh of either lineage. Payload expiresAt in the past: no GET, evidence owner-unproven. 401/403: unproven, no write, cached per digest. Network/5xx/429: unproven, retried no sooner than 60 s, floor stamped at attempt. Owner = profile account.email casefold equal to the row email AND organization.uuid equal to the row org when the row org is non-empty AND account.uuid equal to the row uuid when known. Regression test: fetch_profile and _try_refresh_primary_token patched to raise are never reached from the watcher.
  2. Order (AC5). detect divergence → owner proof with NO lease, remembering the proved authority digest → try the switch lease (held: skip, deferred-switch) → under the lease re-resolve; require the same digest and the same observed/desired row, else skip → locked CAS write with a single-try (≤1 s) config lock.
  3. Placement. The watcher runs AFTER sink.publish(update) and receives the update. Its evidence is held and appended to the NEXT snapshot.
  4. Lock (AC4). Critical section = read, CAS, atomic write (reuse _safe_replace), never network I/O, target under 1 s; release in finally with a bounded-retry rmdir; stale lock (mtime older than 10 s) cleared with rmdir only (never rmtree; a non-empty dir is not ours: fail and retry later). After release, re-read and verify uuid/email/org; mismatch logs post-verify-mismatch and strict callers raise ClaudeConfigUnwritable. Claude Code's storageV5 globalConfig path may not use ~/.claude.json.lock; correctness rests on CAS + post-verify, and AC6 adds a live check of whether a Claude Code config save creates the lock dir.
  5. Merge predicate (AC3). MERGE when email (casefold, stripped) and normalized org are equal AND (both uuids equal OR the row uuid is unknown); the merge keeps the existing accountUuid and every field jacked does not know, and overwrites only fields jacked knows. REPLACE when email or org differ, or both uuids are known and differ. Never backfill the row uuid from the config.
  6. createdAt (AC5). Config missing accountCreatedAt while the stored profile has created_at = incomplete: one republish, counted by the breaker. Compare raw strings, no date parsing.
  7. Logging. Fix the raw email in the authority_guard.py WARNING (~368-372) in this PR.
  8. Not done: skipping the GET for jacked-committed digests (L-B) - divergence right after a jacked commit is rare and the proof is cached per digest.

Interface contracts (parallel tasks code against these)

Tasks

Task 1: identity storage (AC1)

Task 2: builder + safe writer (AC2-AC4)

Task 3: watcher (AC5)

Task 4: gates and live proof (AC6)



Generated with the jacked HTML artifact template. Edit freely.