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.
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.{...old, ...V} with V = accountUuid (account.uuid), emailAddress (account.email), organizationUuid (organization.uuid), hasExtraUsageEnabled (organization.has_extra_usage_enabled ?? false), billingType (organization.billing_type), accountCreatedAt (account.created_at), subscriptionCreatedAt (organization.subscription_created_at), ccOnboardingFlags (organization.cc_onboarding_flags ?? {}), claudeCodeTrialEndsAt / claudeCodeTrialDurationDays (organization.claude_code_trial_* ?? null), seatTier (organization.seat_tier ?? null), planDisplayName (organization.plan_display_name, validated, else omitted), displayName / fullName (account.display_name / account.full_name, only when truthy), profileFetchedAt.subscriptionType and rateLimitTier in the credential payload). A minimal block therefore re-arms an unguarded merge in the next process that refreshes, whatever account it holds.organizationName, organizationRole, workspaceRole come from a separate roles call (merged into the existing block).${configPath}.lock DIRECTORY (mkdir, mtime-based staleness, no files inside).account.{uuid,email,display_name,full_name,created_at,...}, organization.{uuid,name,organization_type,billing_type,rate_limit_tier,seat_tier,has_extra_usage_enabled,subscription_created_at,claude_code_trial_ends_at,claude_code_trial_duration_days,...}. Token exchange uses account.email_address.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).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.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.<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.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.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).skill_listing._synced_dir: it mirrors Claude Code's own folder lookup from the same config, so it becomes right when the config is right.sync_credential_to_all_stores path (zero production callers, ~30 test patch sites); it is routed through the builder here and its removal is filed.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]
| File | Change |
|---|---|
jacked/web/database.py | 3 columns (migration after org rebuild, CREATE TABLE, whitelist, model, create_account); helper listing claude rows missing profile. |
jacked/web/auth.py | Whole-update email/org guard in _update_profile_metadata; store uuid/profile/time; fetch_profile(..., allow_refresh=True); startup backfill function. |
jacked/web/oauth.py | Token-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.py | update_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.py | Publisher 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. |
| tests | test_claude_identity.py, test_identity_watcher.py, profile-guard and migration tests, updates to runtime/observer/launch tests. |
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.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.sink.publish(update) and receives the update. Its evidence is held and appended to the NEXT snapshot._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.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.accountCreatedAt while the stored profile has created_at = incomplete: one republish, counted by the breaker. Compare raw strings, no date parsing.authority_guard.py WARNING (~368-372) in this PR.account_uuid: str | None; profile_json: str | None = JSON text {"account": {...}, "organization": {...}} exactly as the profile endpoint returned those two objects; profile_fetched_at: float | None epoch seconds.jacked/api/claude_identity.py: IdentityBlock (frozen: fields: dict, complete: bool, account_id: int, email: str, organization_uuid: str | None, account_uuid: str | None); build_identity_block(row, *, db=None) -> IdentityBlock | None (None for non-claude provider; db used only for the shared-uuid refusal); is_same_account(existing: dict, block) -> bool; apply_identity(config: dict, block) -> None; write_identity(home, block, *, strict=False, expected: dict | None = None, lock_timeout: float = 5.0) -> WriteResult with WriteResult.status in {written, unchanged, cas_aborted, locked, unreadable, symlink, write_failed, post_verify_mismatch}; strict raises ClaudeConfigUnwritable for every status except written/unchanged; read_claude_config_identity(home) -> dict | None.jacked/api/identity_watcher.py: IdentityWatcher with injectable clock, profile_lookup, lease_factory, resolve, writer; run(update, db, home) -> None; drain_evidence() -> tuple[str, ...]._update_profile_metadata guard + storage; tests: match, email mismatch, org mismatch, empty row org, missing account object.email_address one-liner; tests with real-shaped fixtures.claude_identity.py: NQt mapping, minimal fallback, provider and shared-uuid refusals; tests per field incl. absent optionals._seed_oauth_account, sync path through it; payload tier non-null; update existing tests.identity_watcher.py with injectable clock, lease, profile lookup and writer; observer integration.Generated with the jacked HTML artifact template. Edit freely.