State machine
CaseState.status is a finite-state field. It transitions through a defined sequence as events apply. Each transition is deterministic.
States
Section titled “States”| State | Meaning | Triggered by |
|---|---|---|
intake | Initial state, no facts captured | session creation |
awaiting_customer | Installer handed off, customer hasn’t started | installer_handoff_complete or generate_customer_link |
customer_active | Customer has started the journey | record_personal_facts or record_financial_facts (only when current status is awaiting_customer) |
quote_ready | Eligibility passed, quote configurator showing | record_eligibility (all yes); also held across record_provisional_quote |
submitting | Application submitted, waterfall about to run | submit_application |
waterfall_running | Waterfall is iterating lenders | setWaterfall() with no acceptance, no counter, not exhausted; also re-entered after refuse_counter_offer |
awaiting_counter_decision | Counter-offer presented, customer choosing | setWaterfall() with awaitingCounterDecision: true |
selected | Customer accepted an offer | select_offer, accept_counter_offer, or setWaterfall() with an acceptedOffer |
declined | Waterfall exhausted, no offer | setWaterfall() with exhausted: true |
ineligible | Eligibility failed | record_eligibility (any no) |
withdrawn | Customer withdrew | withdraw |
complete | Generic terminal | case_complete (only fires if not already in a more specific terminal state) |
Transition diagram
Section titled “Transition diagram” ┌─────────────────────────────┐ │ ▼intake ──► awaiting_customer ──► customer_active ──► quote_ready ──► submitting │ │ ▼ ▼ ineligible waterfall_running │ ▼ awaiting_counter_decision │ ┌──────────────┼──────────────┐ ▼ ▼ ▼ selected declined (back to waterfall_running on refuse)
withdraw can fire from any non-terminal state and lands in: withdrawnThe same flow as a mermaid state diagram (rendered when the docs site builds with mermaid support; otherwise readable as source):
stateDiagram-v2 [*] --> intake intake --> awaiting_customer: installer_handoff_complete awaiting_customer --> customer_active: record_personal_facts / record_financial_facts customer_active --> quote_ready: record_eligibility (all yes) customer_active --> ineligible: record_eligibility (any no) quote_ready --> submitting: submit_application submitting --> waterfall_running: setWaterfall (no offer, not exhausted) submitting --> selected: setWaterfall (acceptedOffer) submitting --> awaiting_counter_decision: setWaterfall (awaitingCounterDecision) submitting --> declined: setWaterfall (exhausted) waterfall_running --> selected: setWaterfall (acceptedOffer) waterfall_running --> awaiting_counter_decision: setWaterfall (awaitingCounterDecision) waterfall_running --> declined: setWaterfall (exhausted) awaiting_counter_decision --> selected: accept_counter_offer awaiting_counter_decision --> waterfall_running: refuse_counter_offer state "any non-terminal" as nt nt --> withdrawn: withdraw selected --> [*] declined --> [*] ineligible --> [*] withdrawn --> [*] complete --> [*]Terminal states
Section titled “Terminal states”Five terminal states: selected, declined, ineligible, withdrawn, complete. Once the case enters any of these, no further state transitions happen. The chat is locked, the audit log records the outcome, and the case is closed.
See Withdraw and outcomes for the customer-facing semantics.
How transitions happen
Section titled “How transitions happen”Every transition is in applyEvents in lib/server-store.ts. The function is a switch over event types, with each case mutating s.case synchronously.
Example, the eligibility transition:
case "record_eligibility": { const data = evt.data as Omit<EligibilityFacts, "capturedAt">; const allPass = data.isOver18 && data.isUkResident && data.isHomeowner && data.isEmployed; s.case.eligibility = { ...data, capturedAt: now }; s.case.status = allPass ? "quote_ready" : "ineligible"; break;}Three properties of every transition:
- Synchronous and pure. No async, no I/O. Just a state mutation.
- Idempotent where it matters. Re-applying the same event doesn’t break things.
- No model involvement. The model’s output can produce events, but the transition itself is decided by case state and the event payload, not by model judgement.
Reconciliation, not transition
Section titled “Reconciliation, not transition”Some “transitions” don’t fit cleanly into a single event. Example: when consent is granted, three things should happen:
- The consent record is added.
- The linked disclosure (
credit_search_consent) is acknowledged. - The next disclosure (
pre_contract_summary) is presented.
Only (1) is a direct effect of the capture_consent event. (2) and (3) are consequences. They live in reconcileSession(), not in applyEvents.
The discipline is: applyEvents does ONE thing per event. Cross-cutting consequences live in reconciliation. This makes the state machine readable.
Status as a UI signal
Section titled “Status as a UI signal”The customer page reads caseState.status to decide what to render:
intake/awaiting_customer→ loading statecustomer_activeand earlier disclosures presented → render whichever card matches the next gatequote_ready→ render quote configuratorsubmitting→ render “submitting your application…” placeholderwaterfall_running→ render the WaterfallProgress component animatingawaiting_counter_decision→ render the CounterOfferCardselected→ render the OfferCard withselectedstyledeclined→ render the DeclineNarrative componentineligible→ render the ineligible-path cardwithdrawn→ render the paused-state card
Status is a coarse signal. The customer page also looks at finer-grained state (which disclosures are presented but not acknowledged, what the waterfall result is, etc.) to decide what to show next.
Why a state machine
Section titled “Why a state machine”The alternative would be deriving “what should the UI show” from the chat history. That works but is fragile: it requires re-deriving on every render, requires the chat history to be authoritative, and breaks when the chat history is incomplete or noisy.
A state machine is more boring, but: it’s the same on every read, it’s testable, it’s serialisable (which is what enables the seed pattern), and it’s what an audit reviewer wants to read.
See Reconciliation for how stuck cases get unstuck.