Where users drop.
And what each drop costs the GTM.
Six journeys, mapped step by step against the actual code. The reported "I have to sign in twice" and "it's slow" symptoms trace to specific lines. Every dropped journey is a 5/1/3 activation that doesn't happen — and every pilot user (Indelible warm-circle first, TAI second) we lose is a sponsorship dollar we don't earn.
- The "sign in twice" report is a real bug with a specific root cause:
prompt: 'login'in the Auth0 config forces re-authentication every time, even with a valid refresh token. One-line fix. - "It's slow" is 14–20 sequential roundtrips on iOS first sign-in, including 5–7 uncached Auth0 Management API calls. ~1.5–4 seconds before the user sees anything. Caching the management token alone saves ~700ms.
- A second "sign in twice" bug is buried in
CompletePhase: the onboarding-finished signal is fire-and-forget. If the GQL call fails or the app is backgrounded mid-call, the user redoes onboarding on the next session. - Welcome screen has a 1400ms intentional animation delay before the Get Started button appears. Combined with the cold-start latency above, the first 3 seconds of every brand-new install are dead air.
- Every dropped journey costs a magic-number activation. The GTM measures users who hit 5 events + 1 magic moment + 3 returns in 14 days — starting with the Indelible Ventures warm-circle pilot, before the Tokyo beachhead opens. Every sign-in friction and onboarding bug halves the funnel into that metric.
- All 24 recommendations together are ~51 hours. Most are 1–3 hour fixes. The "sign in twice" primary cause is a one-line config change.
What every journey is racing toward.
From the GTM (§05): 5 events · 1 magic moment · 3 return visits · in 14 days. The activation contract. Every dropped journey below is a user who didn't reach it.
Five meaningful actions in the product — message, task, meeting prep, agent ask, calendar interaction. Each journey on this page is one of those events.
The agent does something the user couldn't have done alone — a prep brief lands, a task is auto-extracted, a transcript answers a question. Granola built an entire company around this single moment on day one; clearing the same bar is table stakes for the AI-native space.
Three sessions across 14 days. Below the threshold, the user won't form habit. Notifications and the unified inbox carry this load — see Foundation.
Why this matters for the journeys below. The TAI pilot's hybrid venue sponsorship pays per actual user join, not per seat. A user who can't sign in, gets stuck in onboarding, or hits an empty channel doesn't count toward the magic number — and doesn't count toward the sponsorship invoice. The fixes on this page are the difference between a pilot that pays back and one that doesn't.
First sign-in
Brand-new user opening the app for the first time
Mobile users abandon pages taking >3s at 53% (Google). Today the iOS cold-start sits squarely in that range.
- 01
- 02
- 03
- 04
- 05remove
Delete — comment admits it 'may work without it'.
at
AuthContext.tsx:399–405 - 06
- 07cache
Cache management token (24h TTL).
at
SignInUserMutation.php:94–123 - 08cache
Cache email lookup keyed on auth0_id.
at
SignInUserMutation.php:425–434 - 09parallel
Parallelize + cache role/perm fetches.
at
Auth0RoleService.php:36–54 - 10cache
Reuse SDK instance + cache page.
at
RefreshAuth0PermissionsService.php:21–55 - 11background
Dispatch as background job — not blocking.
at
SignInUserMutation.php:585–605 - 12remove
Delete — signInUser already returns the user.
at
AuthContext.tsx:286–342 - 13
- 14parallel
Run in parallel with bootorg.
at
useBootstrapOrg.ts:116–194 - 15
Bugs & friction found
"Native iOS keeps kicking users out — but PWA stays signed in"
PWA uses Auth0's `@auth0/auth0-react` SDK with `cacheLocation: 'localstorage'` and silent SDK-managed refresh — robust. Native iOS (Nova/Capacitor) reimplements the entire flow manually: `expo-auth-session` for the PKCE handshake, `expo-secure-store` (Keychain) for tokens, and a manual `POST /oauth/token` for refresh. Three known failure modes here: (a) Keychain access can throw on cold-start before the secure enclave is unlocked, returning null and triggering re-login; (b) the refresh-token POST has no retry on 5xx — a single network blip drops the user; (c) there's no AppState listener to refresh tokens when the app foregrounds after a long background. The PWA never hits any of this because the Auth0 SDK handles it. The fix is not to harden three different bugs — it's to consolidate native iOS onto the same Auth0 SDK behavior.
nova/src/contexts/AuthContext.tsx (manual restore + refresh) · expo-secure-store usage · no AppState listener for token refresh "Sometimes I have to sign in twice" — primary cause
`prompt: 'login'` is hardcoded in the Auth0 config, which forces Auth0 to ignore any cached SSO session and show the login screen even when a valid refresh token exists. A returning user with a fresh device session still gets the login prompt.
AuthContext.tsx:88 (web) · nova/src/contexts/AuthContext.tsx:314 (mobile) "Sign in twice" — secondary cause
`isInitialized` resets to false whenever Auth0 SDK briefly toggles `isAuthenticated` (which happens during silent refresh). The auth-flow effect then re-runs `signInUserMutation` + `getMeQuery` — a second full sign-in roundtrip.
AuthContext.tsx:417–426 Silent redirect-to-login on transient error
`getToken` retries `getAccessTokenSilently` once with `cacheMode: 'off'`; if that also fails (network blip, 429), it calls `loginWithRedirect` with no warning. Indistinguishable from session expiry by the user.
AuthContext.tsx:218–247 Management API token not cached
`fetchEmailFromAuth0` and `Auth0RoleService::addRole` independently fetch fresh Management API tokens on every login, even within the same request. The token has a 24-hour TTL — caching saves 2+ HTTP calls per login.
SignInUserMutation.php:425–434 · Auth0RoleService.php:24–32 Recommendations
Consolidate native iOS auth onto a single SDK path
Why: PWA works because Auth0's React SDK handles token storage, silent refresh, and AppState transitions correctly. Native iOS reimplements all three manually and gets each one slightly wrong. Either (a) move Nova to Capacitor + Auth0 SDK for parity, or (b) wrap the manual flow with: AppState listener → silent refresh on foreground; retry-with-backoff on the refresh POST; deferred Keychain reads gated on `isReady`. The Keychain timing race is community-tracked at expo issue #23924. Pilot-blocker because it's the bug users feel most.
services/nova/src/contexts/AuthContext.tsx (restore + refresh) · expo-secure-store calls · add AppState.addEventListener Drop `prompt: 'login'`
Why: Returning users with valid refresh tokens should not see Auth0's login screen. This is the single biggest fix for the "sign in twice" report. Auth0 also recommends validating the auth_time claim to confirm freshness rather than relying on the prompt parameter alone.
lib/zunou-react/contexts/AuthContext.tsx:88 · nova/src/contexts/AuthContext.tsx:314 Cache Auth0 Management API token
Why: 24-hour TTL. Saves 2 HTTP calls per login (~700ms on iOS).
services/api/app/Services/Auth0RoleService.php:24–32 · SignInUserMutation.php:425–434 Make `ensureUserHasPersonalPulses` a background job
Why: Today it blocks the sign-in response with N+1 DB queries. The user's first session doesn't need pulses provisioned synchronously — the bootstrap query fetches them on demand. Tracked in Foundation Tier-1 as part of the notification-outbox + queueing rebuild.
services/api/app/GraphQL/Mutations/SignInUserMutation.php:407,585–605 → new Job class Remove the redundant `getMeQuery`
Why: `signInUserMutation` already returns the user. The second call is dead weight — saves a full roundtrip.
lib/zunou-react/contexts/AuthContext.tsx:286–342 Don't reset `isInitialized` on every `auth0IsAuthenticated` flip
Why: Use a one-shot ref to gate `handleAuthFlow`. Eliminates the double sign-in.
lib/zunou-react/contexts/AuthContext.tsx:417–426 Replace `loginWithRedirect` fallback with a typed retry toast
Why: Transient errors should surface a 'try again' UI, not silently bounce the user back through Auth0.
lib/zunou-react/contexts/AuthContext.tsx:218–247 Remove the 100ms safety buffer
Why: The comment already admits it 'may work without it.' Test and delete.
lib/zunou-react/contexts/AuthContext.tsx:399–405 Onboarding (post sign-in)
First-time user after sign-in completes
Onboarding's job is to deliver the first 5 events + 1 magic moment in this session.
- 01improve
Show button instantly; play animation in parallel.
at
WelcomePhase.tsx (animation delay) - 02
- 03improve
Skip-and-fade UI for already-linked.
at
CalendarPhase.tsx:36–38 - 04
- 05
- 06
- 07improve
Make blocking before navigation (+200ms for durability).
at
CompletePhase.tsx:191–215
Bugs & friction found
"Sign in twice" — true root cause (onboarding-side)
`CompletePhase.handleFinish` fires `upsertNovaSettingMetadata` as fire-and-forget before navigating to `/(app)`. If the app is backgrounded/killed in the sub-second window before the GQL mutation returns, the backend `onboardedMobile` flag never gets set. On next cold start (or iOS AsyncStorage wipe), `useBootstrapOrg` finds no `onboardedMobile=true`, `useOnboardingStore` reads `completed=false` from blank storage, and the user is sent back to /onboarding. User experience: 'I have to sign in again.'
CompletePhase.tsx:191–215 · useBootstrapOrg.ts:225–227 Race between create-org and bootstrap-org
After `createOrganization` resolves, the phase rebuilds itself and bumps the index. But `useBootstrapOrg` may still be running in parallel — when it lands, it calls `setOrg` which clears `pulseId`. Result: a flash of empty state.
CreateOrgPhase.tsx:146–153 1400ms intentional delay on welcome screen
Button doesn't appear for 1.4 seconds. No skip. Compounds the slow-sign-in pain.
WelcomePhase.tsx (animation delay) Calendar auto-skip flash
If Google Calendar is already linked, the phase calls `nextPhase()` from a `useEffect` synchronously on mount. The 350ms cross-fade can't mask the single-frame flash of an empty screen.
CalendarPhase.tsx:36–38 No idempotency on org creation if user re-enters
If user kills the app mid-onboarding after createOrg succeeded but before completion, restart re-shows create-org. A second submit creates a duplicate org (no client-side or server-side de-dupe).
CreateOrgPhase.tsx Recommendations
Make `upsertNovaSettingMetadata` blocking before navigation
Why: Eliminates the #1 user-reported bug. The user already waited through onboarding — 200ms more for durability is the right trade.
services/nova/src/components/onboarding/phases/CompletePhase.tsx:191–215 Server-side `onboardedAt` as source of truth
Why: Treat AsyncStorage as a cache; trust the backend. Cleanly recovers when iOS wipes AsyncStorage.
services/nova/src/hooks/useBootstrapOrg.ts:225–227 · settings.metadata schema Kill the 1400ms welcome delay
Why: Animations should not gate input. Show the button immediately; play the animation in parallel.
services/nova/src/components/onboarding/phases/WelcomePhase.tsx (animation/timeout) Skip-and-fade for already-linked calendar
Why: Render the success state for 400ms with a 'Calendar already connected · →' affordance instead of vanishing.
services/nova/src/components/onboarding/phases/CalendarPhase.tsx:36–38 Idempotency key on createOrganization
Why: Client generates a UUID per onboarding session; backend dedupes. Prevents double-org bugs on app restart mid-flow.
services/nova/src/components/onboarding/phases/CreateOrgPhase.tsx:120–158 · CreateOrganizationMutation.php Calendar setup (Google OAuth)
User connecting Google Calendar for first time
Without calendar, the user sees an empty Schedule tab — the magic moment that Granola wins on.
- 01
- 02
iOS platform limit — can't avoid.
- 03
User-controlled — out of our hands.
- 04
- 05
- 06improve
Enforce account match — reject mismatch with retry UI.
- 07
- 08remove
Auto-advance after 1.5s success state.
Bugs & friction found
Wrong Google account selected silently links wrong calendar
`login_hint: user?.email` pre-fills but does not enforce. If a different Google account is selected, `linkGoogleCalendar` may succeed with the wrong identity — and there's no UI feedback flagging the mismatch.
useLinkedAccounts.ts:117–139 (login_hint at :135) iOS users retype Google password every link
ASWebAuthenticationSession creates an ephemeral session that doesn't share Safari cookies. Even users signed into Google in Safari must reauthenticate.
expo-auth-session AuthRequest.promptAsync (iOS implementation) Extra tap after success
After linkGoogleCalendar resolves and shows the 'Calendar Connected!' state, user must tap Continue to advance. Could auto-advance with a 1.5s success state.
CalendarPhase.tsx Recommendations
Auto-advance after successful link
Why: Show success state for 1.5s with a countdown indicator, then advance. Saves a tap and feels modern.
services/nova/src/components/onboarding/phases/CalendarPhase.tsx (success block) Enforce account match on success
Why: Compare granted Google email vs auth user email; if mismatch, surface a clear error with retry. Prevents silent wrong-account linking.
services/nova/src/hooks/useLinkedAccounts.ts:117–139 (login_hint at :135) Server-side calendar prefetch
Why: Start fetching events the moment OAuth succeeds, in parallel with the user dismissing the success screen. First Schedule tab paint is instant.
services/api/app/Jobs/FetchUserGoogleCalendarEventsJob.php · trigger after linkGoogleCalendar mutation First message in a channel
Newly-onboarded user wanting to message someone
Sending a first message = +1 event toward the 5/1/3 magic number.
- 01
- 02
- 03
- 04improve
Add empty-state fallback + one-tap fix for failed provisioning.
- 05
- 06
Bugs & friction found
Empty channel list if `ensureUserHasPersonalPulse` failed silently
The provisioning step in `signInUser` is best-effort. If it failed during sign-in (DB error, race), the user lands on Chats with no channel and no clear empty-state component — only the FAB. Bewildering first-message experience.
SignInUserMutation.php:407 · chats/index.tsx (no empty-state in first 60 lines) Personal pulse is invisible without context
Even when present, the personal pulse is just one row in the channel list with no 'this is your scratch space' framing. New users don't know they can message themselves.
useBootstrapPulse.ts auto-select logic Recommendations
Empty-state with one-tap fix
Why: If channel list is empty post-onboarding, show a card: 'Looks like we missed setting up your space. Tap to fix.' One tap re-triggers provisioning.
services/nova/src/app/(app)/(tabs)/chats/index.tsx · empty-state branch First-run framing of personal pulse
Why: A subtle 'Your space · for drafts and reminders' label below the personal pulse for the first week.
services/nova/src/hooks/useBootstrapPulse.ts · channel row component in chats/index.tsx Cross-link first-action prompts on Home
Why: The Feed already has card slots — surface a 'Send your first message' card until the user does.
services/nova/src/components/feed/FeedPanelContent.tsx · feed.ts FeedItemKind enum Create a new channel
User starting a new team or DM thread
A user who creates a channel is forming the social graph — strongest predictor of week-2 retention.
- 01
- 02improve
Pre-populate menu with last-3 contacted.
- 03
- 04improve
Instant search on first keypress (100ms debounce).
- 05improve
Server-side DM idempotency by (creator, recipient).
Bugs & friction found
DM duplicate guard depends on client cache
`useChatsPage.ts:2673–2683` checks cached `chatChannels` for existing ONETOONE before creating. On a cache miss (first load, or after reinstall), a duplicate is created. Backend has no de-dupe.
useChatsPage.ts:2673–2683 300ms search debounce + 1-char minimum delays results
Search activates after 1 character with 300ms debounce. Feels sluggish vs Slack's instant-on-keypress. Compounds in the 'find someone' flow.
useChatsPage.ts (search debounce config) Recommendations
Server-side DM idempotency by (creator, recipient)
Why: Treat DM creation as an upsert. Eliminates duplicate-DM bugs once and for all.
services/api/app/GraphQL/Mutations/CreatePulseMutation.php · upsert by (creator_id, recipient_id, pulse_type=ONETOONE) Instant search on first keypress
Why: Drop the 1-char gate and debounce to 100ms. Search results feel like Slack.
services/nova/src/hooks/useChatsPage.ts · search debounce config Pre-populate FAB menu with last-3-contacted
Why: Most DM compose actions go to recent people. One tap from FAB if they're top-3.
services/nova/src/app/(app)/(tabs)/chats/index.tsx · FAB menu component Create a task
User capturing a to-do
Captured tasks = ongoing engagement events. Linear-level capture speed = Linear-level retention.
- 01
- 02
- 03
- 04
- 05remove
Inline assignee chip — no extra screen.
- 06improve
Optimistic insert + snackbar-undo on failure.
Bugs & friction found
No quick-assign on inline-create
Inline create captures title only. Assignment requires opening the full TaskFormSheet — 3 extra taps. Linear does it inline.
TaskInlineCreate.tsx · TaskFormSheet.tsx:72 Recommendations
Inline assignee chip on the composer row
Why: An always-visible 'Me' chip with a tap-to-change picker. Brings tap count to 2 even when assigning. Linear-tier.
services/nova/src/components/tasks/TaskInlineCreate.tsx · assignee picker sheet Optimistic insert with snackbar-undo
Why: Task appears immediately on send. If backend fails, snackbar offers 'Tap to retry' instead of an error dialog.
services/nova/src/components/tasks/TaskInlineCreate.tsx · createTask mutation onMutate / onError What this means for the GTM.
Six journeys, 18 concrete bugs, 24 fixes, ~51 engineering hours. Every fix is a small percentage point on the activation funnel. Multiplied across the TAI pilot, those points compound into the 5/1/3 number the GTM is gated on.
GTM
The strategy these journeys are paying back. Hybrid venue sponsorship + community-led launch + stage-gate Feb 2027.
Open the strategyFoundation
The engineering work that closes the bugs above and the reliability gaps behind them. Tier 1 ROI-ranked.
See the build planQuality
How we keep these journeys working as we ship. Definition of Done + test stack + 100% as the asymptote.
See the contractWhere this gets us
The smallest fixes here have the largest leverage on the pilot.
One Auth0 config line for "sign in twice." One promise-await for the fire-and-forget onboarding bug. Two hours for a Management API token cache. The pilot funnel doesn't widen with grand architecture — it widens by closing the specific paper cuts users feel in the first 90 seconds.
The activation contract is 5 / 1 / 3. Every journey on this page is a step toward it — or away from it.