You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A mobile user on TestFlight build 992 reached a broken authenticated state: the app shell and cached greeting remained visible, but conversation data could not load. The production conversation-list endpoint returned repeated 401 Unauthorized responses, and the client retried indefinitely instead of resolving to a clear reauthentication state.
This is not a build-992/TestFlight routing regression. The relevant iOS environment/auth/conversation paths did not change between TestFlight builds 990 and 992; the code involved predates both releases. TestFlight is where the symptom was reported, not the proven cause.
User impact
The experience looks like data loss:
User sees the normal signed-in shell and cached name (for example, Hey Nik).
Conversations/data do not appear because /v1/conversations is unauthorized.
The UI keeps retrying instead of explaining that the session needs recovery/re-login.
Server data is not deleted; it is inaccessible to the client while its session is unusable.
Production evidence (sanitized)
During the incident window that overlaps the supplied TestFlight screenshot:
Observation
Value
Window
2026-07-10 03:00:03Z–03:20:46Z
Matching requests
81
Endpoint
GET /v1/conversations?include_discarded=false&limit=50&offset=0&statuses=
Response
401 Unauthorized
Cadence
median 15.2 seconds (min 1.8s, max 18.3s)
Client user agent
Dart/3.9 (dart:io)
Server revision
backend-d1bb4c6-1
No correlated backend auth/deployment error was found in the same window. This is not evidence of a broad backend outage.
Root-cause chain
A previously signed-in Firebase user needs a forced ID-token refresh:
AuthService.getIdToken() returns null for a generic Firebase/SDK/network refresh failure, calling it transient.
If the cached token is expired, getAuthHeader() clears it; the HTTP header builder catches AuthTokenUnavailableException and proceeds withoutAuthorization.
The production API correctly returns 401.
The generic HTTP 401 handler tries another refresh, then calls AuthService.signOut() if no usable token is available.
The conversation provider sees a failed fetch and schedules retries. After initial backoff it continues every 15 seconds, including after terminal auth failure.
The initial Firebase refresh error is not observable from Cloud Run logs, so this issue must instrument it. Possibilities include a transient refresh/network failure, revoked credential, Keychain persistence problem, or Firebase SDK/platform failure. Do not assume TestFlight is itself logging users out without client evidence.
Relevant implementation / history
1. Missing token becomes an unauthenticated request
The HTTP 401 path subsequently turns that null into a sign-out attempt. This inconsistent transient-vs-terminal policy is the main auth-recovery defect.
3. Conversation retry makes terminal auth loss look like missing data
3d27bc7471, Mohammed Mohsin: failed initial conversation fetches retain cache and auto-retry.
892ef467a8, Mohammed Mohsin: after fast retries, continue retrying every 15 seconds.
The observed production request cadence matches this 15-second path.
4. Stale identity makes the session look valid
AuthService._clearCachedAuth() clears only authToken and tokenExpirationTime; cached profile values such as givenName are retained. Therefore a greeting can remain visible when the server session is unusable.
Scope
Fix both linked failure modes as one auth-recovery change:
Token-refresh failure must not degrade into anonymous API traffic.
A terminal auth failure must stop conversation retries and transition the UI to a clear recovery/re-login state.
Proposed implementation direction
Introduce a typed result/error that distinguishes:
token refresh transient failure;
missing token;
terminal/revoked/invalid session;
backend-rejected refreshed token.
Do not make authenticated API requests when no token is available.
For transient refresh failure, use a bounded auth-refresh retry state; do not call signOut() solely because a single refresh attempt returned null.
For confirmed terminal 401 after refresh:
sign out once;
cancel conversation retry timers and pending authenticated work;
clear user-scoped UI/cache state as appropriate;
route to sign-in and show a user-safe Session expired — sign in again message.
Ensure the app cannot keep rendering an apparently authenticated shell after a completed sign-out.
A signed-in user whose forced token refresh transiently fails does not send Authorization: Bearer or no-auth requests to authenticated endpoints.
A terminal token failure or a second 401 after refresh signs out exactly once and presents sign-in/recovery UI.
Conversation fetching does not schedule retries after a terminal auth failure; no 15-second /v1/conversations 401 loop.
The app does not present a stale signed-in identity/home shell after sign-out.
Telemetry records a privacy-safe auth_token_refresh_failed event with failure class/code, platform, app version/build, and release channel; it must not include token, UID, email, or request body.
Telemetry records authenticated_request_401 and whether refresh/re-auth recovered.
Add a signed TestFlight/iOS auth smoke or canary covering persisted login, forced token refresh, and authenticated conversation-list access.
Validation notes
TestFlight 992 source: 820379296 (Bump build number to 992 for mobile release #9329).
The relevant auth/retry commits above are ancestors of both builds 990 and 992.
No relevant auth/conversation/environment-routing paths changed in the 990 → 992 diff.
Existing unit coverage tests the local missing-header guard and token-cache branches, but not the full end-to-end failure mode: generic refresh failure → 401 → sign-out/UI transition → no provider retry loop.
Related historical context
#499 identified token-expiry/silent-sign-in correctness as a mobile requirement, but is closed and does not cover this current failure chain.
#6848 is the intentional crash-prevention change whose recovery path needs completion.
Summary
A mobile user on TestFlight build 992 reached a broken authenticated state: the app shell and cached greeting remained visible, but conversation data could not load. The production conversation-list endpoint returned repeated
401 Unauthorizedresponses, and the client retried indefinitely instead of resolving to a clear reauthentication state.This is not a build-992/TestFlight routing regression. The relevant iOS environment/auth/conversation paths did not change between TestFlight builds 990 and 992; the code involved predates both releases. TestFlight is where the symptom was reported, not the proven cause.
User impact
The experience looks like data loss:
Hey Nik)./v1/conversationsis unauthorized.Server data is not deleted; it is inaccessible to the client while its session is unusable.
Production evidence (sanitized)
During the incident window that overlaps the supplied TestFlight screenshot:
GET /v1/conversations?include_discarded=false&limit=50&offset=0&statuses=401 UnauthorizedDart/3.9 (dart:io)backend-d1bb4c6-1No correlated backend auth/deployment error was found in the same window. This is not evidence of a broad backend outage.
Root-cause chain
AuthService.getIdToken()returnsnullfor a generic Firebase/SDK/network refresh failure, calling it transient.getAuthHeader()clears it; the HTTP header builder catchesAuthTokenUnavailableExceptionand proceeds withoutAuthorization.AuthService.signOut()if no usable token is available.The initial Firebase refresh error is not observable from Cloud Run logs, so this issue must instrument it. Possibilities include a transient refresh/network failure, revoked credential, Keychain persistence problem, or Firebase SDK/platform failure. Do not assume TestFlight is itself logging users out without client evidence.
Relevant implementation / history
1. Missing token becomes an unauthenticated request
2c714a451c/ PR #6848, Matt Van Horn:AuthTokenUnavailableExceptionhandling inapp/lib/backend/http/shared.dart;Authorization;This was a reasonable crash mitigation, but it lacks a complete recovery contract.
2. Refresh failure is explicitly categorized as transient, then escalated to sign-out
f545a30ba7, beastoin:The HTTP 401 path subsequently turns that
nullinto a sign-out attempt. This inconsistent transient-vs-terminal policy is the main auth-recovery defect.3. Conversation retry makes terminal auth loss look like missing data
3d27bc7471, Mohammed Mohsin: failed initial conversation fetches retain cache and auto-retry.892ef467a8, Mohammed Mohsin: after fast retries, continue retrying every 15 seconds.The observed production request cadence matches this 15-second path.
4. Stale identity makes the session look valid
AuthService._clearCachedAuth()clears onlyauthTokenandtokenExpirationTime; cached profile values such asgivenNameare retained. Therefore a greeting can remain visible when the server session is unusable.Scope
Fix both linked failure modes as one auth-recovery change:
Proposed implementation direction
signOut()solely because a single refresh attempt returned null.Session expired — sign in againmessage.Acceptance criteria
Authorization: Beareror no-auth requests to authenticated endpoints./v1/conversations401 loop.auth_token_refresh_failedevent with failure class/code, platform, app version/build, and release channel; it must not include token, UID, email, or request body.authenticated_request_401and whether refresh/re-auth recovered.Validation notes
820379296(Bump build number to 992 for mobile release #9329).Related historical context