Context
From Slack thread on #14396 (xstate/statechart question for billing ops). Same pattern as #14300 (workspace-switch state machine consolidation): imperative flags/status field plus separate per-transition side-effect functions, instead of a discriminated union with one owned transition/reducer function.
Problem
src/platform/workspace/stores/billingOperationStore.ts manages 3 operation types x 4 statuses (pending/succeeded/failed/timeout), 3 different timeout thresholds, recursive polling with backoff, and toast/telemetry/dialog side effects per terminal transition.
BillingOperation has a status: OperationStatus field but is not a true discriminated union — fields like actionUrl and authenticationRequiredSeen are typed as always-present but are only meaningful while status === 'pending'. Nothing in the type system prevents a 'succeeded' or 'timeout' operation from carrying a stale actionUrl.
handleSuccess(), handleFailure(), handleTimeout() are three separate functions, each independently doing: status update, cleanup (timers/toasts), telemetry, toast, and terminal-promise resolution. No single function owns "how does this operation reach a terminal state" — the shape is repeated 3 times with small variations, easy to drift.
Proposed fix
Convert BillingOperation into a real discriminated union on status, each variant carrying only its relevant fields (e.g. actionUrl/authenticationRequiredSeen only exist on the pending variant). Replace handleSuccess/handleFailure/handleTimeout with one transition function that takes the current operation and an outcome, returns the next operation state, and the side effects (cleanup/telemetry/toast/resolve) run once, driven by the transition result rather than duplicated per outcome.
No new dependency needed (skip xstate, per Slack discussion — team consensus was bundle size isn't the blocker, it's the added concept/onboarding cost, and a plain TS union + reducer gets most of the safety).
Reference
Context
From Slack thread on #14396 (xstate/statechart question for billing ops). Same pattern as #14300 (workspace-switch state machine consolidation): imperative flags/status field plus separate per-transition side-effect functions, instead of a discriminated union with one owned transition/reducer function.
Problem
src/platform/workspace/stores/billingOperationStore.tsmanages 3 operation types x 4 statuses (pending/succeeded/failed/timeout), 3 different timeout thresholds, recursive polling with backoff, and toast/telemetry/dialog side effects per terminal transition.BillingOperationhas astatus: OperationStatusfield but is not a true discriminated union — fields likeactionUrlandauthenticationRequiredSeenare typed as always-present but are only meaningful whilestatus === 'pending'. Nothing in the type system prevents a'succeeded'or'timeout'operation from carrying a staleactionUrl.handleSuccess(),handleFailure(),handleTimeout()are three separate functions, each independently doing: status update, cleanup (timers/toasts), telemetry, toast, and terminal-promise resolution. No single function owns "how does this operation reach a terminal state" — the shape is repeated 3 times with small variations, easy to drift.Proposed fix
Convert
BillingOperationinto a real discriminated union onstatus, each variant carrying only its relevant fields (e.g.actionUrl/authenticationRequiredSeenonly exist on thependingvariant). ReplacehandleSuccess/handleFailure/handleTimeoutwith one transition function that takes the current operation and an outcome, returns the next operation state, and the side effects (cleanup/telemetry/toast/resolve) run once, driven by the transition result rather than duplicated per outcome.No new dependency needed (skip xstate, per Slack discussion — team consensus was bundle size isn't the blocker, it's the added concept/onboarding cost, and a plain TS union + reducer gets most of the safety).
Reference
endWorkspaceSession()inworkspaceAuthStore.tsis the equivalent "one owned transition" for the auth-recovery case