{"record":{"id":"1d940bd5035a239f","repo":"zeroclaw-labs/zeroclaw","slug":"email-oauth2-profile-is-missing-token-set-profil","errorCode":null,"errorMessage":"Email OAuth2 profile is missing token set: {profile_id}","messagePattern":"Email OAuth2 profile is missing token set: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-providers/src/auth/mod.rs","lineNumber":538,"sourceCode":"            return Ok(Some(token_set.access_token.clone()));\n        }\n\n        let Some(refresh_token) = token_set.refresh_token.clone() else {\n            // No refresh token; return the (possibly expired) access token and\n            // let the IMAP auth failure surface as a log event.\n            return Ok(Some(token_set.access_token.clone()));\n        };\n\n        let refresh_lock = refresh_lock_for_profile(&profile_id);\n        let _guard = refresh_lock.lock().await;\n\n        // Re-load after acquiring lock to avoid duplicate refreshes.\n        let data = self.store.load().await?;\n        let Some(latest_profile) = data.profiles.get(&profile_id) else {\n            return Ok(None);\n        };\n        let Some(latest_tokens) = latest_profile.token_set.as_ref() else {\n            anyhow::bail!(\"Email OAuth2 profile is missing token set: {profile_id}\");\n        };\n        if !latest_tokens.is_expiring_within(Duration::from_secs(SKEW_SECS)) {\n            return Ok(Some(latest_tokens.access_token.clone()));\n        }\n\n        let refresh_token = latest_tokens.refresh_token.clone().unwrap_or(refresh_token);\n\n        if let Some(remaining) = refresh_backoff_remaining(&profile_id) {\n            anyhow::bail!(\n                \"Email OAuth2 token refresh is in backoff for {remaining}s due to previous failures\"\n            );\n        }\n\n        let mut refreshed = match refresh_email_access_token_with_retries(\n            &self.client,\n            token_url,\n            client_id,\n            &refresh_token,","sourceCodeStart":520,"sourceCodeEnd":556,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-providers/src/auth/mod.rs#L520-L556","documentation":"Raised by get_valid_email_oauth2_token after it acquires the per-profile refresh lock and re-loads the auth store: the profile still exists but its token_set field is now None. The first (pre-lock) load saw a token set, so between the two loads another writer cleared it (e.g. profile removal/re-creation, a downgrade to a non-OAuth profile kind, or a partial store overwrite). The refresh path refuses to continue without tokens to refresh.","triggerScenarios":"Two concurrent get_valid_email_oauth2_token calls for the same channel alias racing with a store update that writes profile.token_set = None; an external process (zeroclaw auth logout / profile edit) mutating the same auth store JSON while an IMAP connect is refreshing; a profile replaced with a bare profile that has no token_set.","commonSituations":"Running multiple channel workers against one state dir, re-importing or resetting a profile while email polling is live, or a hand-edited/corrupted auth store where a profile object lost its token_set key.","solutions":["Stop concurrent writers: only mutate email OAuth profiles via the auth store APIs (store_email_oauth2_tokens / update_profile) so token_set is never cleared by partial writes","Re-run `zeroclaw auth login` for the email channel (or re-do the email OAuth2 consent flow) so the profile has a valid token_set again","If another long-running zeroclaw instance shares the same state dir, stop it or give each instance its own state dir","Inspect the profile in the auth store JSON and delete the broken profile entry so it is re-created cleanly on next login"],"exampleFix":"// before: overwriting the whole profile drops token_set\nstore.upsert_profile(AuthProfile::new_bare(alias, name), true).await?;\n\n// after: mutate in place so the existing token_set survives\nstore.update_profile(&profile_id, |p| {\n    p.kind = AuthProfileKind::OAuth;\n    Ok(())\n}).await?;","handlingStrategy":"try-catch","validationCode":"// Pre-check the profile before asking for a token\nlet data = auth_store.load().await?;\nif let Some(id) = select_profile_id(&data, alias, None) {\n    let ok = data.profiles.get(&id)\n        .and_then(|p| p.token_set.as_ref())\n        .is_some();\n    anyhow::ensure!(ok, \"profile {id} has no token set; re-run email OAuth login\");\n}","typeGuard":null,"tryCatchPattern":"match auth.get_valid_email_oauth2_token(alias, None, url, id, &scopes).await {\n    Err(e) if e.to_string().contains(\"missing token set\") => {\n        // treat as stale profile: trigger re-login flow, do not retry the call\n        start_email_oauth_login(alias).await?;\n    }\n    result => result?,\n}","preventionTips":["Never rewrite whole profile records; use update_profile so token_set survives edits","One zeroclaw instance per state dir to avoid concurrent store writers","Monitor for WARN auth events and re-run login when profiles lose tokens"],"tags":["oauth2","email","auth-store","concurrency","race-condition"],"backgroundTag":"oauth-token-store-race-condition","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}