Skip to content

State machine

CaseState.status is a finite-state field. It transitions through a defined sequence as events apply. Each transition is deterministic.

StateMeaningTriggered by
intakeInitial state, no facts capturedsession creation
awaiting_customerInstaller handed off, customer hasn’t startedinstaller_handoff_complete or generate_customer_link
customer_activeCustomer has started the journeyrecord_personal_facts or record_financial_facts (only when current status is awaiting_customer)
quote_readyEligibility passed, quote configurator showingrecord_eligibility (all yes); also held across record_provisional_quote
submittingApplication submitted, waterfall about to runsubmit_application
waterfall_runningWaterfall is iterating lenderssetWaterfall() with no acceptance, no counter, not exhausted; also re-entered after refuse_counter_offer
awaiting_counter_decisionCounter-offer presented, customer choosingsetWaterfall() with awaitingCounterDecision: true
selectedCustomer accepted an offerselect_offer, accept_counter_offer, or setWaterfall() with an acceptedOffer
declinedWaterfall exhausted, no offersetWaterfall() with exhausted: true
ineligibleEligibility failedrecord_eligibility (any no)
withdrawnCustomer withdrewwithdraw
completeGeneric terminalcase_complete (only fires if not already in a more specific terminal state)
┌─────────────────────────────┐
│ ▼
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: withdrawn

The 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 --> [*]

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.

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:

  1. Synchronous and pure. No async, no I/O. Just a state mutation.
  2. Idempotent where it matters. Re-applying the same event doesn’t break things.
  3. 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.

Some “transitions” don’t fit cleanly into a single event. Example: when consent is granted, three things should happen:

  1. The consent record is added.
  2. The linked disclosure (credit_search_consent) is acknowledged.
  3. 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.

The customer page reads caseState.status to decide what to render:

  • intake / awaiting_customer → loading state
  • customer_active and earlier disclosures presented → render whichever card matches the next gate
  • quote_ready → render quote configurator
  • submitting → render “submitting your application…” placeholder
  • waterfall_running → render the WaterfallProgress component animating
  • awaiting_counter_decision → render the CounterOfferCard
  • selected → render the OfferCard with selected style
  • declined → render the DeclineNarrative component
  • ineligible → render the ineligible-path card
  • withdrawn → 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.

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.