Skip to content

refactor(web): model session lifecycle as one discriminated union#33219

Merged
ashleeradka merged 8 commits into
mainfrom
devin/lum-2220-platform-session-enum
Jun 3, 2026
Merged

refactor(web): model session lifecycle as one discriminated union#33219
ashleeradka merged 8 commits into
mainfrom
devin/lum-2220-platform-session-enum

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Prompt / plan

Approved follow-up to the additive consolidation (#33203): retire the parallel-boolean
session representation in the web auth store in favor of single discriminated unions, the
model change deferred and tracked in LUM-2220. Opened as a draft and tagged for design
coordination because it rewrites the auth store's public contract, which sits in the
LUM-1788 (gateway-as-BFF) blast radius.

The store had three layers of the same anti-pattern stacked on top of each other. This PR
fixes all three so the store reads as a state machine and no consumer re-encodes its
internals.

What changed

1. sessionStatus — session lifecycle as one union (was isLoggedIn + isLoading)

type SessionStatus = "initializing" | "authenticated" | "unauthenticated";
  • "initializing" — the boot probe has not settled; logged-in status is not yet known.
  • "authenticated" — a user is signed in (platform or local gateway).
  • "unauthenticated" — the probe settled with no session.

The (isLoggedIn, isLoading) pair had four combinations but only three legal ones —
(true, true) ("logged in yet still loading") was meaningless, and every reader had to
remember which combination meant what. The union makes the illegal state unrepresentable
and names each phase once.

2. platformSession — liveness as one tri-state (was hasPlatformSession + platformSessionResolved)

type PlatformSessionStatus = "unknown" | "absent" | "present";

This is the change from #33203's deferred model: two booleans encoded one concept
(session liveness) across two fields, making "false that really means unknown"
representable. Any reader that grabbed hasPlatformSession without also gating on
platformSessionResolved silently treated the pre-settle probe window as a confirmed
negative. The union makes that unrepresentable.

3. Read side — predicates own each question once

Consumers stopped re-encoding enum literals (=== "authenticated", === "present") at
~10 call sites. Each question is answered in one place:

  • Pure predicates in the dependency-free stores/session-status module —
    isAuthenticated(status), isSessionSettled(status), hasLivePlatformSession(status)
    — for imperative readers (auth middleware, resolveLocalOnboardingRoute, the lifecycle
    service) that already hold a value.
  • Reactive hooks in the store — useIsAuthenticated(), useIsSessionInitializing(),
    useHasPlatformSession() — that compose the predicates over atomic selectors, for
    components.

The raw platformSession field stays exported for the two sites that genuinely need the
tri-state (isPlatformFunnelAvailable, resolveLocalOnboardingRoute).

4. Write side — named transitions instead of hand-listed field combos

The ~13 inline set({...}) combinations across initSession's three mode branches,
refreshSession, connectLocalAssistant, logout, and biometric recovery collapse into a
few named transitions — authenticatedPlatformUser(user), authenticatedLocalUser(),
sessionEnded() — so the actions declare which state they enter rather than poking
fields.

DRY: one reachability gate (carried from #33203)

The three local gateway auth entry points — initSession, refreshSession, and
connectLocalAssistant — share one gate, probePlatformSessionIfReachable, so "what
counts as reachable" has a single definition.

Breaking an import cycle the correct way

The lifecycle service is a value the store imports (for resetForLogout), but it also
needs to read session meaning. import type can't break that cycle because the predicates
are runtime values, not types. Rather than leak status === "authenticated" back into the
lifecycle service, the pure types and predicates live in a dependency-free
stores/session-status module that both the store and its dependencies import. This is a
cohesive domain module (the session-state model), not a -types.ts cycle band-aid — it
owns runtime logic, which is why it is not the import type case AGENTS.md warns against.

Test plan

  • bun test (isolated) for auth-store, auth-middleware, lifecycle-service,
    preferences-menu, inspect-page, onboarding-lifecycle-sync, profile-card,
    use-platform-gate, local-onboarding-route, general-page, prechat-steps — all
    green. Test mocks and fixtures migrated from the boolean pairs to the enums.
  • bunx tsc --noEmit — clean (the unions remove the overloaded false/boolean pair, so a
    reader that forgets a gate is now a type error rather than a silent race).
  • bun run lint — clean (0 errors; pre-existing exhaustive-deps warnings only).

Root cause analysis

  1. How did the code get into this state? Both concepts were introduced as a single
    boolean; the "is it known yet?" / "is it still loading?" dimension was bolted on later
    as a second boolean rather than promoting the field to a status. Two booleans for a
    three-state concept left one combination overloaded.
  2. What decisions led to it? Adding state incrementally (a flag at a time) instead of
    modeling the state space. Each addition looked locally minimal but globally created an
    unrepresentable-states gap, then leaked its encoding to every reader.
  3. Warning signs missed? The platform-session race surfaced three times — onboarding
    funnel (fix(web): gate onboarding Google step on a live platform session, not a cached id #33123), middleware fork (fix(web): await the platform-session probe before the onboarding hosting/welcome fork #33201), and the latent retire-assistant navigation
    (refactor(web): consolidate platform-session waits behind one primitive and fork resolver #33203). Three instances of one bug class is the signal to model the state, not patch
    readers.
  4. Prevent recurrence? Model async/multi-phase status as a single discriminated union
    from the start; extract a shared predicate the moment the same derivation appears in a
    second module.
  5. AGENTS.md / docs guidance? Added two short, linkable rules to
    docs/STATE_MANAGEMENT.md — "status as one discriminated union, never parallel
    booleans" and "where derivation lives: inline vs shared predicate" — capturing the
    final wording once now that the reshape is settled.

Coordination

This rewrites the auth store's public shape, which is hot and multi-owner and sits in the
LUM-1788 (gateway-as-BFF) redesign blast radius. Tagging @noaflaherty for a design ack
before merge so the model converges with the BFF track rather than being redone when that
work resumes.

References

Closes LUM-2220.

Link to Devin session: https://app.devin.ai/sessions/15bca57bd4c64a3085cfb80e1f26355a
Requested by: @ashleeradka

Replace the `hasPlatformSession` + `platformSessionResolved` boolean pair in
the auth store with one `PlatformSessionStatus = "unknown" | "absent" |
"present"`.

The two booleans encoded one concept — session liveness — across two fields,
which made "false that really means unknown" representable: any reader that
grabbed `hasPlatformSession` without also gating on `platformSessionResolved`
silently treated the pre-settle probe window as a confirmed negative. That
ambiguity caused the same class of onboarding-fork race three times. The union
makes the ambiguous state unrepresentable: readers that want liveness compare
to `"present"`; the one imperative consumer that must wait reads
`!== "unknown"`.

The mapping is exact, so every reader migrates mechanically:
- `hasPlatformSession` ≡ `platformSession === "present"`
- `platformSessionResolved` ≡ `platformSession !== "unknown"`

One internal behavior change: the probe no longer reopens the `"unknown"`
window on app-resume re-probes. Only the initial boot starts from `"unknown"`;
a re-run leaves the last `"present"`/`"absent"` in place until the new result
lands, so reactive consumers (`usePlatformGate`, `useIsOrgReady`, root layout,
organization store) keep showing the last-known session instead of flickering
to disabled/redirecting on every resume. No reader re-waits mid-session, so
this is safe.

Closes LUM-2220.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@linear

linear Bot commented Jun 3, 2026

Copy link
Copy Markdown

LUM-2220

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@noanflaherty — design ack requested before this merges, since it rewrites the auth store's public platform-session shape and sits in the LUM-1788 (gateway-as-BFF) blast radius.

The mechanical part is behavior-preserving (hasPlatformSessionplatformSession === "present", platformSessionResolvedplatformSession !== "unknown"). The one judgment call worth your eyes:

The probe no longer reopens the "unknown" window on app-resume re-probes — only the initial boot starts from "unknown"; re-runs keep the last "present"/"absent" until the new result lands. That's what preserves the optimistic reactive consumers (usePlatformGate, useIsOrgReady, root layout) so they don't flicker to disabled/redirect on every resume. See the probe docstring in apps/web/src/stores/auth-store.ts and the no-flicker reasoning.

Holding the docs/STATE_MANAGEMENT.md convention note ("model multi-state status as a union, not parallel booleans") until you've weighed in, so we word it once. Kept as draft — won't merge without your sign-off or coordination with the BFF track.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

devin-ai-integration Bot and others added 3 commits June 3, 2026 18:17
…helper

The three local gateway auth entry points (initSession, refreshSession,
connectLocalAssistant) each inlined the same reachability gate: probe the
platform session when one could exist, otherwise settle directly to
"absent". Collapse them onto a shared probePlatformSessionIfReachable
helper so the gate has a single definition.

connectLocalAssistant's copy still settled the pre-enum platformSessionResolved
boolean; routing it through the helper settles the tri-state "absent" instead,
matching the other two paths.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Collapse the auth store's `isLoggedIn`/`isLoading` boolean pair into a single
`sessionStatus: "initializing" | "authenticated" | "unauthenticated"` enum,
alongside the existing `platformSession` tri-state, so the contradictory
"logged in yet still loading" state is unrepresentable.

Three layers of repetition are removed:

- Read side: consumers no longer re-encode enum literals
  (`=== "authenticated"`, `=== "present"`). Pure predicates
  (`isAuthenticated`, `isSessionSettled`, `hasLivePlatformSession`) own each
  question once, with matching reactive hooks (`useIsAuthenticated`,
  `useIsSessionInitializing`, `useHasPlatformSession`) composing them over
  atomic selectors.
- Write side: the hand-listed `set({...})` field combos collapse into named
  transitions (`authenticatedPlatformUser`, `authenticatedLocalUser`,
  `sessionEnded`) so the actions read as a state machine.
- The boolean-pair anti-pattern itself becomes the single `sessionStatus` enum.

The pure types and predicates live in a dependency-free `session-status`
module so modules the store depends on (the assistant lifecycle service) can
read session meaning without an import cycle — `import type` cannot break a
cycle through a runtime value. Reactive components read the hooks from the
store; imperative readers (middleware, route resolvers, lifecycle) call the
predicates directly.

Documents the discriminated-union and shared-predicate conventions in
STATE_MANAGEMENT.md.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration devin-ai-integration Bot changed the title refactor(web): model platform-session liveness as a single tri-state refactor(web): model session lifecycle as one discriminated union Jun 3, 2026
@ashleeradka ashleeradka marked this pull request as ready for review June 3, 2026 19:16

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 78fe427a4e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/stores/auth-store.ts Outdated

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment thread apps/web/docs/STATE_MANAGEMENT.md
ashleeradka and others added 2 commits June 3, 2026 19:28
…n enum reshape

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…probe

resolveLocalOnboardingRoute reads platform-session liveness imperatively.
A re-probe leaves the prior "present"/"absent" displayed while it confirms
in the background (so reactive consumers don't flicker), so the one-shot
fork could read a stale value rather than the fresh probe result.

Expose whenPlatformSessionSettled() from the auth store and have the
resolver await it (under the same 5s bound as the boot wait) before
choosing hosting vs welcome, decoupled from the displayed tri-state so
reactive readers are unaffected. Covered by a regression test.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@ashleeradka

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4652598e68

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/stores/auth-store.ts Outdated
The settle accessor returned the current probe's promise by value, so a
probe that became latest after a caller began waiting was not awaited:
resolveLocalOnboardingRoute could proceed when the probe it first observed
settled (now stale) while a newer one was still in flight, reading a stale
platformSession. Loop until the module-level settle reference is unchanged
across an await, so the wait covers any probe launched mid-wait. Add a
regression test for a probe that becomes latest after the wait begins.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE (with merge-gate notes below)

Value: This is textbook illegal-state elimination on the auth store. The (isLoggedIn, isLoading) pair had four combinations but only three legal ones — (true, true) was meaningless and every reader had to remember which pairs meant what. Same anti-pattern stacked one layer down on (hasPlatformSession, platformSessionResolved), where "false that really means unknown" let pre-settle readers silently treat the probe window as a confirmed negative. Both collapse into discriminated unions; both make the illegal state unrepresentable.

Layered cleanly:

  1. SessionStatus = "initializing" | "authenticated" | "unauthenticated" — boot lifecycle, names each phase once
  2. PlatformSessionStatus = "unknown" | "absent" | "present" — liveness as one value
  3. Read side — isAuthenticated(status) / hasLivePlatformSession(status) / isSessionSettled(status) predicates + matching reactive hooks (useIsAuthenticated, useIsSessionInitializing, useHasPlatformSession) compose the predicates over the store's atomic selectors. ~10 call sites stop re-encoding enum literals at the boundary.

The stores/session-status module is the right call. Dependency-free, so the lifecycle service (which the store itself depends on) can read session meaning without creating an import cycle. The docstring explains why import type couldn't break that cycle — predicates are runtime values. Good engineering decision worth the new file.

Verified substantive code at HEAD 4641b8b20f
  • probePlatformSession correctly resolves overlapping probes via latestPlatformProbe ID + isStale() guards on all three branches (.then / .catch / .finally). Probes never reopen the "unknown" window (so reactive consumers don't flicker on app-resume refreshes), and only the initial boot probe starts from "unknown" — the .finally() resolves first-time "unknown""absent" when neither branch confirmed a session. Read it; matches the docstring's "latest-wins" + "no flicker" claims.

  • whenPlatformSessionSettled chases the module-level reference exactly as Devin described in the Codex P2 #2 rebuttal:

    let awaited = platformProbeSettled;
    await awaited;
    while (platformProbeSettled !== awaited) {
      awaited = platformProbeSettled;
      await awaited;
    }

    Correct: after each await, re-checks whether a newer probe replaced the reference and waits that one out too; returns only once the reference is unchanged across an await. The previous by-value version returned the originally-captured promise and would let a stale settle proceed while a newer probe was still in flight. The regression test ("waits for a probe that becomes latest after the wait begins") locks this in.

  • Docs sweep: Devin caught + fixed the STATE_MANAGEMENT.md:275 stale isLoggedIn example AND a sibling stale docstring in prechat-steps.ts referencing the removed isLoading field. The intentional isLoggedIn/isLoading references at STATE_MANAGEMENT.md:203/212 (illustrating the retired anti-pattern) are correctly preserved.

Codex P2 #1 (bf14db21de) — Devin's mutually-exclusive-gates argument, then hardening anyway

Codex flagged: refreshSession() starts an async probe and returns before getSession() settles, so resolveLocalOnboardingRoute() could read a stale "absent" and redirect a freshly-authed user to /onboarding/welcome instead of /onboarding/hosting.

Devin's investigation: resolveLocalOnboardingRoute() has exactly one caller (auth-middleware.ts:43), which only fires when isLocalMode() && !hasAssistants(). In that state, refreshSession()'s probe takes the synchronous else branch and sets platformSession: "absent" immediately — it does NOT start an async getSession() probe. The two conditions (probe-launch gate fires vs resolver gate fires) can't co-occur. Specific misroute = not reachable.

But — Devin then hardened anyway in 4652598e68 with whenPlatformSessionSettled() + 5s deadline awaiting probe-settle in addition to the boot !== "unknown" wait. That's the right call: relying on a non-obvious cross-module invariant for correctness is a footgun. The hardening makes resolveLocalOnboardingRoute() correct independent of the gate-mutual-exclusion invariant.

Non-blocking, resolved.

Codex P2 #2 (4652598e68) — real gap, fixed in 4641b8b20f

Codex caught: whenPlatformSessionSettled() returned platformProbeSettled by value, so a probe that became latest AFTER the caller began waiting wasn't awaited. First-observed probe settles stale, no-ops, then resolver proceeds and reads platformSession while the newer probe is still in flight.

Devin acknowledged as a real gap and fixed by chasing the module-level reference (code above). Added regression test verified to fail against by-value version and pass with the fix.

Resolved — this is the substantive correctness improvement in the final commit.

Anti-pattern scan
  • No as casts on runtime-boundary shapes ✓
  • Discriminated unions used correctly; no (status as "authenticated") smuggling ✓
  • Pure predicates separated from reactive hooks (no useStore.getState() hidden in selectors) ✓
  • No new async/concurrent paths beyond the explicit probe coordination ✓
  • TanStack Query / Zustand patterns untouched in this PR — consumer-side mechanical migration only ✓
  • No /// Swift-style comments ✓
  • Test coverage: auth-store.test.ts +124/-50, local-onboarding-route.test.ts +6/-21, auth-middleware.test.ts +7/-16, regression test for the new chase-latest behavior ✓
  • Single-purpose new module (session-status), justified by import-cycle reasoning ✓

Territory (R11e): Auth store + boot-probe lifecycle + onboarding route fork. Boss/Noa territory (this is the LUM-1788 gateway-as-BFF blast radius noted in the PR body). Not Vargas (SSE/reconciliation) or Mahmoud (vembda). ✓

Merge-gate status at HEAD 4641b8b20f:

  • ✅ Vex APPROVED (this review)
  • ⚠️ Devin clearance is stale: "No Issues Found" was at d8f853040e (first commit, 4 commits ago). Engagement at HEAD is author-fixing-own-Codex-P2, not reviewer-clearing the chase-latest pattern.
  • ⚠️ Codex hasn't seen HEAD: last review at 4652598e68 (the commit containing the bug Devin then fixed in HEAD). Need fresh Codex pass on the chase-latest implementation.
  • ⚠️ Noa design ack pending: Devin pinged @noanflaherty at 10:58 AM for design coordination because this rewrites the auth store's public contract. No reply yet (~9h). PR was opened as a draft for exactly this reason; the draft flag was flipped but the human-in-the-loop intent stands.
  • ✅ CI 7/7 success

Recommendation: Hold merge until Noa acks + fresh Codex review at HEAD clears. Triggering Codex now (next comment). If Codex 👍s the chase-latest fix and Noa lands a design ack, gate is met.

Vellum Constitution — Yours: this PR is the kind of work that pays compounding interest. Every reader that stops asking "is logged-in-and-loading even a thing?" is one less footgun for the next feature. The encoding lives in one module forever.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review

Re-review request: the P2 you flagged on 4652598e68 (probe-becoming-latest-after-wait-begins) was addressed in 4641b8b20f by making whenPlatformSessionSettled() chase the module-level platformProbeSettled reference instead of capturing it once. Validate that the chase-latest loop closes the race for all caller paths.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ashleeradka ashleeradka merged commit 76419a8 into main Jun 3, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/lum-2220-platform-session-enum branch June 3, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant