Journey Stage Machine

17 min read•docs/JOURNEY-STAGE-MACHINE.md•

Journey stage machine (T1.4)

Canonical state diagram + transition rules for CX Orbit. This is a documentation/spec lock. It does not add a new engine.

Runtime already follows this machine:

Layer Where
client.current_stage @cx-orbit/profile applyStageTransition (T2.9)
Funnel first-reach @cx-orbit/web applyStageTransitionForHistory (T3.2) — same rules
Stage enum JOURNEY_STAGES in packages/profile/src/constants.ts

Related: METRICS-AND-RISK.md §4, RISK-RULES-V1.md §5 and §9, DATA-MODEL.md §3–4, event dictionary SEC-02 in simiaroom-cx-v5.md.


1. SEC-02 alignment

SEC-02 principle How this machine encodes it
Path is not linear Pause, completion, churn, return, and rematch are first-class. Funnel spine is a display order, not the only allowed life.
State from timed, traceable events; a click is not a transition Only canonical backend/system events in §4 move current_stage. Behavioral clicks (page_viewed, therapist_options_viewed, login_clicked, assessment_started, …) are stage-neutral.
Pause, cadence drop, completion, and churn are independent Separate stages and separate flags. T3.3 / T3.4 must not collapse them into one “stopped” rule.
Interpretation uses history, not only current state Funnel counts first-reach. Risk/retention use flags, session_count, cadence, rematch count.
Identity by ID-01 rules, not IP identified requires authentication_succeeded or identity_link_created. Anonymous-only events never enter the funnel cohort.
Acute crisis leaves the normal path Not a journey stage. Crisis routing is out of this machine (no crisis value in JOURNEY_STAGES).

2. Stages listed for funnel display

Two different uses of the same enum. Do not mix them.

2.1 Linear spine (T3.2 conversion table)

Displayed in this order. Conversion is reached(N) / reached(N−1). These are not current-stage buckets.

Order Stage id Authoritative first-reach event Funnel label intent
1 identified authentication_succeeded or identity_link_created Resolved client entered
2 assessed assessment_completed Assessment finished
3 matched therapist_selected Therapist chosen (initial or later)
4 booked booking_confirmed Booking confirmed
5 paid payment_succeeded Payment succeeded
6 in_treatment session_completed At least one completed session

identified has no previous stage; conversion is null (UI —).

Cohort size = clients whose first identified event falls in the required [from, to] window.

2.2 Non-linear branches (T3.2 side counts, not denominators)

Branch id How counted current_stage value?
paused First-reached paused via therapy_pause_declared Yes — paused
returned_from_pause Pause then therapy_resumed No — resume writes in_treatment
completed First-reached completed via treatment_completed Yes — completed
churned First-reached churned via stop / churn confirm Yes — churned

A client who paused after session 1 still keeps in_treatment first-reach. Branches are extra, not a replacement column.

There is no funnel branch returned_from_churn. Churn → return is a current_stage path only (§7). T3.2 must not grow a seventh conversion stage for it.

2.3 Serving-only values (not funnel columns)

Stage Meaning
unknown Initial current_stage before any identifying event. Not a funnel row. Incomplete identity is excluded from T3.2 / T3.3 / T3.4 denominators.

JOURNEY_STAGES = unknown, the six spine ids, paused, completed, churned.


3. State diagram

Linear arrows are the happy-path spine. Dashed / labeled arrows are non-linear. Skip-forward on the spine is allowed by the engine when a later authoritative event arrives without the earlier one (illegal for producers — §8).

stateDiagram-v2
  [*] --> unknown

  unknown --> identified: authentication_succeeded / identity_link_created
  identified --> assessed: assessment_completed
  assessed --> matched: therapist_selected
  matched --> booked: booking_confirmed
  booked --> paid: payment_succeeded
  paid --> in_treatment: session_completed

  in_treatment --> paused: therapy_pause_declared
  paused --> in_treatment: therapy_resumed

  in_treatment --> completed: treatment_completed
  paused --> completed: treatment_completed

  in_treatment --> churned: therapy_stop_declared / churn_confirmed / churn_reason_submitted
  paused --> churned: therapy_stop_declared / churn_confirmed / churn_reason_submitted

  churned --> in_treatment: therapy_resumed

Rematch does not appear as a stage: therapist_selected with selection_context=replacement stays on the current stage (§7.2).

Pause expiry does not appear as a stage: current_stage stays paused until resume, stop, or completion. Risk/retention flip via pause_expired, not via a new stage id.


4. Transitions driven by events (not clicks)

Replay events in UTC occurred_at order; tie-break source_event_id lexicographic (same as T2.9 / T3.2).

4.1 Stage-changing events

Canonical type From To Notes
authentication_succeeded unknown or any non-terminal stage earlier than identified identified (or unchanged if already later) Also the usual cohort-entry event
identity_link_created same identified ID-01 merge; still not IP-only
assessment_completed non-terminal, earlier than assessed assessed Clicks on questions do not count
therapist_selected non-terminal, earlier than matched matched Replacement: see §7.2 (usually no stage change)
booking_confirmed non-terminal, earlier than booked booked
payment_succeeded non-terminal, earlier than paid paid
session_completed non-terminal, earlier than in_treatment in_treatment If current is paused, stay paused (session still increments session_count)
therapy_pause_declared any except that linear advance is skipped paused Sets current_pause_flag
therapy_resumed paused, churned, or completed in_treatment Else no-op. Clears pause flag. Does not clear completion_flag
treatment_completed any completed Sets completion_flag; clears pause flag
therapy_stop_declared any churned Clears completion_flag
churn_confirmed any churned Same. risk_v1 confirms churn from SIG-STOP, not from gap
churn_reason_submitted any churned Stage follows stop; reason is payload

Terminal for linear advance: completed and churned. Later spine events (session_completed, payment_succeeded, …) do not move those stages. Return from churn/completion uses therapy_resumed only.

Monotonic spine: a later spine event may skip forward (identified + payment_succeeded → paid). An earlier spine event never moves the client backward.

4.2 Stage-neutral events (recompute may run; stage must not jump)

These may trigger SYS-01 profile recompute. They must not change current_stage:

Canonical type Why it is not a stage
payment_failed Failure is a risk signal (SIG-PAY-FAIL), not “un-paid”
booking_cancelled Cancel does not regress booked (T2.9 residual, locked here)
session_no_show_recorded Does not increment session_count; does not enter in_treatment
unplanned_gap_detected CON-S01 indicator; not churn
risk_state_entered Risk overlay; risk does not drive the stage machine
churn_suspected Suspicion ≠ churned
internal_case_resolved Ops action outcome

Clicks and behavioral events (not in the table, default = no stage change): page_viewed, landing_page_viewed, login_clicked, assessment_started, assessment_question_answered, therapist_options_viewed, therapist_profile_viewed, booking_started, return_intent_detected, and any other GA/product click. SEC-02: a click is not a transition.

therapist_change_requested / therapist_change_reason_submitted are rematch intent, not a stage. Stage stays put until a later therapist_selected (usually still no stage change — §7.2).


5. Pause and completion suppressions

Stage, funnel, retention, and risk all see the same events. They apply different suppressions. Do not reuse continuation_status=stopped as churn (T2.4 maps completion that way; T3.3 must use completion_flag).

5.1 Declared pause — `therapy_pause_declared` (CON-04)

Layer Rule
Stage current_stage = paused. current_pause_flag = true.
Funnel (T3.2) Count branch paused. Do not remove linear first-reach. Resume sets branch returned_from_pause and current_stage = in_treatment.
Retention (T3.3) Never churn while pause is active. CON-S01 cadence breach suppressed.
Risk (T1.7 / T3.4) Suppress SIG-GAP-CADENCE, SIG-GAP-LONG, SIG-GAP-FALLBACK, SIG-NO-BOOKING. Cap level at needs_attention. High-risk tab excludes planned pauses. Do not emit CHR-S02 during active pause.

Active pause = current_pause_flag AND NOT pause_expired.

pause_expired (does not change stage):

Condition Result
expected_return_date present and as_of date > that date, no later therapy_resumed Expired (T2.4 pause_return_breach)
expected_return_date present and as_of date ≤ that date Active
expected_return_date missing Active for 14 UTC calendar days after the pause event, then expired

When expired: T3.3 counts a dangerous-pause indicator (not churn). T3.4 lifts the cap and fires SIG-GAP-PAUSE-BREACH. current_stage remains paused until therapy_resumed, stop, or completion.

5.2 Completion — `treatment_completed` (CHR-S04)

Layer Rule
Stage current_stage = completed. completion_flag = true. Pause flag cleared.
Funnel (T3.2) Count branch completed. Keep linear first-reach.
Retention (T3.3) Not churn. Use completion_flag, not session continuation_status.
Risk (T1.7 / T3.4) Stop scoring. Return healthy, risk_reasons = []. Exclude from at-risk and churn-as-failure. Do not emit CHR-S01/S02/S03.

Completion is a successful operational outcome. It is not a clinical claim.

5.3 True churn (for contrast)

therapy_stop_declared or churn_confirmed → current_stage = churned, completion_flag = false.

T3.3 churn-after-N requires a stop event, completion_flag = false, pause not active, and session_count = N. Gap and expired pause are not churn.

T3.4: SIG-STOP → high_risk, queue kind churned, not preventable at-risk.


6. Flags vs stage (do not collapse)

Flag Set by Cleared by Independent of
current_pause_flag therapy_pause_declared therapy_resumed or treatment_completed Cadence, churn
completion_flag treatment_completed therapy_stop_declared or churn_confirmed Pause
pause_expired Clock vs return date / 14-day fallback Resume (flag off) Stage id stays paused until resume/stop/complete
therapist_change_count Replacement therapist_selected Never reset by rematch Stage, session_number

7. Return and rematch paths

7.1 Return

Prior state Return event Next current_stage Funnel
paused (active or expired) therapy_resumed in_treatment Branch returned_from_pause
churned therapy_resumed in_treatment First-reach churned kept; no extra T3.2 branch. T3.4 SIG-STOP lifts. T3.3 still treats a stop-at-or-before-as_of as churn (no resume override in the retention fact builder) — see §8
completed therapy_resumed in_treatment Illegal producer sequence (§8). Stage follows resume; completion_flag stays true so T3.3/T3.4 still treat as completed

Not return: authentication_succeeded, login_clicked, page_viewed, return_intent_detected, or a new booking/session while still churned/completed (linear advance is blocked). SEC-02: login after churn is not reactivation.

Same-therapist vs rematch-on-return is a property (selection_context=resume / replacement), not a new stage.

7.2 Rematch

Rematch is a therapist-change overlay on the existing continuum. It is not a funnel stage and must not reset session_number (T2.4).

Event Stage effect Other effect
therapist_change_requested (CHG-01) None Intent only
therapist_change_reason_submitted (CHG-02) None Reason payload
therapist_selected with selection_context=replacement Stay on current stage if already matched or later Increment therapist_change_count when therapist_id changes; T2.4 sets therapist_changed_since_previous on the next completed session
Later booking_confirmed / session_completed Normal spine rules Continuum continues N, N+1, …

T3.4 SIG-THERAPIST-CHG may fire (attention, not high by itself). Completion still suppresses it.


8. Illegal and ambiguous transitions

Producers must not emit these. The engine still has a deterministic last-event / monotonic rule so replay does not crash. Funnel conversion may look wrong if producers skip the spine.

Case Why it is illegal / ambiguous Engine behavior (locked) Analytics
Click / behavioral event as a stage move SEC-02 No-op Not in T3.2 spine
assessment_started without assessment_completed Incomplete assessment Stay pre-assessed Not funnel.reached.assessed
Skip-forward (e.g. payment_succeeded with no booking_confirmed) Missing authoritative spine event Jump to the later stage First-reach may skip a column; conversion can exceed 100% — treat as DQ, not as a new stage
Backward spine event (e.g. assessment_completed after in_treatment) Out-of-order duplicate No-op; stay later First-reach already recorded
session_no_show_recorded or booking_cancelled as drop to a previous stage Cancel/no-show ≠ un-book / un-treat Stage-neutral T3.3 does not number those rows
churn_suspected or unplanned_gap_detected → churned Gap ≠ churn; suspicion ≠ confirm Stage-neutral T3.3 indicator / T3.4 signal only
Pause declared before any session Pause is a treatment-lifecycle branch Stage becomes paused Branch paused may appear without in_treatment; still not a conversion denominator
session_completed while paused Session during planned pause Stay paused; session_count still +1 First-reach in_treatment unchanged if already set
Login / auth after churned or completed Click/login ≠ return Stay terminal No returned_from_pause
therapy_resumed after a stop (churn → return) Stage returns; T3.3 churn metric does not Stage → in_treatment; T3.4 SIG-STOP requires no later therapy_resumed / session_completed T3.2 keeps first-reach churned. T3.3 isChurned stays true if any stop exists at or before as_of (residual: retention snapshot ≠ current stage)
therapy_resumed while not paused/churned/completed Resume without a leave No-op
therapy_resumed after treatment_completed Completion and return conflict Stage → in_treatment; completion_flag stays true Risk/retention still completed
Both treatment_completed and a stop event Mutually exclusive outcomes Last timestamp wins for stage and flags (stop clears completion) Do not count as both completed and churned in T3.3 (flag after last event)
IP-only or shared-device merge into identified Violates ID-01 Identity layer refuses merge (T2.3) No extra funnel client
Crisis / out-of-scope flag as a stage SEC-02 crisis is a redirect No crisis stage Out of this machine

9. Product journey refs J-01…J-12 (not extra funnel columns)

T1.1 maps product reality onto J-01…J-12: JOURNEY-GAP-ANALYSIS.md. This machine collapses them for P0 display:

J-ref Product idea Machine
J-01 Entry Anonymous / explore / login click Not a stage. identified only after auth or identity link
J-02 Assessment Start / progress / complete assessed on assessment_completed only
J-03 Therapist explore / select Views vs select matched on therapist_selected
J-04 Booking Start vs confirm booked on booking_confirmed
J-05 Payment Fail vs success paid on payment_succeeded; fail is stage-neutral
J-06 Session Scheduled / no-show / completed in_treatment on session_completed
J-07 Feedback ORS/SRS Stage-neutral; risk/feedback metrics
J-08 Continue / pause Cadence vs declared pause paused + flags; cadence is not a stage
J-09 Therapist change Rematch subflow Overlay; stay on current stage
J-10 Churn / completion Independent outcomes churned vs completed
J-11 Return Resume after pause or churn therapy_resumed → in_treatment
J-12 Internal action Cases Stage-neutral

10. Test Agent walkthroughs

Walk each scenario event by event against §3–§4. Expected current_stage is after that event. First-reach is the set of stages entered at least once (T3.2).

W1 and W2 reuse T3.2 fixture clients in fixtures/journey-funnel/p0-cohort.json (cohort window 2026-01-01 … 2026-06-30). W3 extends usr_churn with an explicit resume (not in that fixture).

W1 — Happy path (`usr_full_a`)

# Event occurred_at current_stage after
0 (none) — unknown
1 authentication_succeeded 2026-01-05T08:00Z identified
2 assessment_completed 2026-01-06T09:00Z assessed
3 therapist_selected (selection_context=initial, thr_a) 2026-01-07T10:00Z matched
4 booking_confirmed 2026-01-08T11:00Z booked
5 payment_succeeded 2026-01-08T11:05Z paid
6 session_completed 2026-01-20T10:00Z in_treatment

First-reach: identified, assessed, matched, booked, paid, in_treatment.

Branches: none (not paused, not returned, not completed, not churned).

Suppressions: none. If session_count ≥ 1, T3.4 may score; T3.3 includes the client in reached(1).

Not in this path: page_viewed / therapist browse would not have moved the stage if inserted between 2 and 3.

W2 — Pause then return (`usr_pause_ret`)

Same spine as W1 through session_completed (2026-03-01) → in_treatment, then:

# Event occurred_at current_stage after Flags
7 therapy_pause_declared (current_pause_flag=true) 2026-03-15T10:00Z paused pause on; completion off
8 therapy_resumed 2026-04-01T10:00Z in_treatment pause off

First-reach: all six linear stages plus paused.

Branches: paused = yes, returned_from_pause = yes, completed = no, churned = no.

T3.2: client still counts in in_treatment (first-reach kept).

T3.3: while paused (and not expired), not churn. Gap signals suppressed.

T3.4: while active pause, gap signals suppressed; level capped; not on the high-risk tab. After resume, gap scoring resumes against cadence.

Expired-pause variant (not this fixture): if as_of passed expected_return_date with no resume, stage stays paused, T3.3 dangerous-pause indicator, T3.4 SIG-GAP-PAUSE-BREACH. Still not churn.

W3 — Churn then return (synthetic on `usr_churn`)

Use usr_churn through churn_confirmed, then add resume. Login in between must not return.

# Event occurred_at current_stage after Notes
1–6 Same spine as fixture usr_churn through session_completed 2026-03-05 — in_treatment First-reach full spine
7 churn_confirmed 2026-03-20T10:00Z churned completion_flag false. T3.3 churn-after-1. T3.4 SIG-STOP, not preventable
7b authentication_succeeded (hypothetical login) any later churned Illegal to treat as return. Linear advance blocked
8 therapy_resumed 2026-04-10T10:00Z in_treatment Only legal return

First-reach: linear spine + churned. Resume does not add a T3.2 returned_from_pause branch (no pause event).

T3.3 (step 7 snapshot, as_of after confirm, before resume): churned with session_count = 1. Pause/completion do not apply.

T3.3 (step 8 snapshot): a stop event still exists at or before as_of, so the retention churn flag stays true (T3.3 has no resume override). That is intentional residual, not a stage-machine failure. Test Agent should still accept step 8 as a stage return.

T3.4: after step 7, SIG-STOP → churned queue, not preventable. After step 8, SIG-STOP does not fire (therapy_resumed is later than the stop); score as in-treatment.


11. Out of scope (explicit)

  • T1.1 product-vs-J-ref gap interviews (analysis doc: JOURNEY-GAP-ANALYSIS.md; named ops/PM interview still residual)
  • New funnel columns, new JOURNEY_STAGES values, or engine refactors
  • Crisis routing as a stage
  • P1 behavioral funnel (assessment start, therapist view) as conversion stages — T3.2 residual stands
  • Live Postgres serving (fixture-first, same as T3.2)

12. Change log

Date Change
2026-08-12 T1.4 initial lock: spine + branches aligned with T3.2 / T2.9; pause/completion suppressions aligned with T3.3 and risk_v1; return/rematch and illegal transitions documented; three Test Agent walks.