docs/33 · companion explainer

FlowGate & FlowState — the Graph + the FSM, working together

The bot has two brains. One decides what to do inside a single message (the Graph). The other remembers a task that spans many messages — like booking an appointment (the FSM / Flow). This page shows how they meet at one guard called FlowGate, and how their memory lives in one place called FlowState. Real function names, mock chat data, lots of pictures.

Two kinds of names on this page:
LIVE exists in the code today
NEW proposed in docs/33 (to be built)
Graph (per-turn router)
Flow / FSM (multi-turn task)
FlowGate (the guard)
FlowState / FlowStateStore (the memory)
Services (do the real writes)

1 The big idea in one picture

Think of a hotel concierge. The Graph answers whatever you ask right now. The Flow is the concierge remembering "we're in the middle of booking your dinner reservation." FlowGate is the rule: before answering anything new, check if we're mid-task and continue that first.

The GRAPH — "this one message"

Per-turn router / composer. Re-runs from scratch every message.

Decides which steps run and in what order: search inventory? answer a question? show cars? It's the playbook a dealer (now: our admin) draws on a canvas.

Real code: GraphStepCompiler::walk() LIVE, node types goal · ask · retrieve · tool · decide · say · handoff

The FLOW / FSM — "this whole task"

Multi-turn lifecycle of ONE action. Lives across many messages.

A small state machine: BookingFlow walks idle → awaiting_time → done. It owns the rules: how many reschedules, confirm cancel once, attach the car.

Real code: BookViewingTool / CancelViewingTool LIVE → become BookingFlow / CancelFlow NEW

▼   they meet here   ▼

FlowGate — the guard at the start of every turn NEW

One small step in the pipeline that BOTH brains run, before any fresh routing.

Its only job: "Is a task already open? → continue it. If not → let the Graph route normally." That single rule is what makes the bot predictable: finish what you started before starting something new.

FlowStateStore — the one notebook NEW

The only code allowed to read/write the open-task memory (state.flow).

Today the bot scribbles task-memory in 4 different scattered places (booking_ask, booking_decision, cancel_ask, playbook). FlowStateStore replaces them with one tidy object so we can always answer "what's the bot doing and why."

2 What the memory looks like — state.flow

It's one small chunk of JSON saved on the conversation row (conversations.state, a jsonb column LIVE). At any moment, this fully answers "what task is open, where are we, how many times have we asked."

// conversations.state.flow  —  the ONE open-task slot (NEW, docs/33)
{
  "schema":       1,                  // version of this shape
  "kind":         "booking",          // booking | cancel | graph_ask
  "status":       "awaiting_time",    // where in the lifecycle we are
  "slots": {                             // data gathered so far
    "vehicle_id": 8842,
    "type":       "viewing",
    "date":       null             // still missing → that's why we're asking
  },
  "asks":        { "awaiting_time": 1 },  // re-ask counter (budget guard)
  "priority":     50,                 // who wins if two tasks collide (fixed per kind)
  "engine_scope": "agnostic",         // "agnostic" survives a crash; "graph" does not (see §6)
  "opened_turn":  14,
  "ttl_turns":    3                   // auto-forget if ignored this many turns
}
kind
which task is open
status
the FSM state we're parked at
engine_scope
the safety flag — see the fallback rule (§6)

3 Where FlowGate sits in a turn

Every customer message runs through a list of small steps (TurnPipeline::run() LIVE). FlowGate is just a new step — placed after "understand the message" but before "route / pick what to do." Both brains (the Orchestrator and the Graph engine) run the same pipeline, so FlowGate works the same in both.

stepLocale
→
stepPreFilter
→
stepUnderstand
→
FlowGate ★
→
stepRoute
→
stepInventory
→
stepBooking
→
stepGenerate
→
…guards…
→
stepMeter

blue = LLM reads the message  ·  amber = the new guard  ·  green = an action can happen here. Today the same trick already exists in a small way: TurnSteps::hasPendingBookingFlow() LIVE. FlowGate just generalizes it to every task.

What FlowGate decides (the whole logic)

// FlowGate — runs every turn, BEFORE routing  (NEW, docs/33)
open = FlowStateStore::read(conversation)        // look in the notebook

if open is null:
    return CONTINUE                              // no task → let the Graph route normally

decision = registry.flow(open.kind).transition(open, understanding, world)

match decision.intent:
    HANDLE  → run effects, save next_state, reply       // continue the task
    YIELD   → park the task, let Graph answer ONE thing  // e.g. a side question
    PREEMPT → higher-priority task takes over             // rare, by fixed priority
    ABANDON → close the task, Graph routes fresh          // "never mind"

4 A real conversation, turn by turn

Watch the notebook (state.flow) change. Customer is chatting on LINE about a BYD Atto 3 (vehicle #8842).

TURN 1 — a fresh requestno task open → Graph routes
CUSTOMERI want to come see the BYD Atto 3 🚗
BOTGreat choice! When would you like to visit? We're open 9am–6pm daily.
FlowGate: notebook empty → CONTINUE→ stepInventory finds #8842→ BookingFlow opens: needs a time

FlowStateStore::write(...) saves a brand-new task:

state.flow = {
  "kind": "booking", "status": "awaiting_time",
  "slots": { "vehicle_id": 8842, "type": "viewing", "date": null },
  "asks": { "awaiting_time": 1 }, "engine_scope": "agnostic"
}
TURN 2 — a side question (YIELD)task open, but unrelated question → park & answer once
CUSTOMERwait, what colors does it come in?
BOTThe Atto 3 comes in white, grey, and blue. 🎨 — and we still have your visit to schedule, what time works?
FlowGate: task open→ BookingFlow.transition() → YIELD (not a time)→ Graph answers colors for ONE turn

The task isn't lost — it's parked. Notebook just flips status:

state.flow.status = "parked"   // still there. No second task is allowed to open while parked (no-stack rule, D33-15)
TURN 3 — the answer arrives (RESUME & act)resume parked task → write to the database
CUSTOMERok, tomorrow 2pm
BOTBooked! ✅ Viewing for the BYD Atto 3, tomorrow at 2:00pm. See you then! Can I get your name & phone to confirm?
FlowGate: resume parked booking→ transition() → HANDLE, returns effects[]

Important rule: the flow doesn't write to the DB itself. It requests effects; a tiny executor runs the real services. (Keeps writes in one trusted place.)

// BookingFlow.transition() returns a PURE decision — no DB writes inside (NEW, D33-11)
FlowDecision {
  next_state: "done",
  effects: [
    { do: "book_appointment", vehicle_id: 8842, type: "viewing", at: "2026-06-23 14:00" },
    { do: "attach_interest", vehicle_id: 8842 }
  ],
  terminal: true
}

// the executor performs them, THEN clears the notebook (LIVE services):
AppointmentService::book(lead, "viewing", "2026-06-23 14:00", vehicle_id:8842)
LeadVehicleInterestService::attach(lead, 8842, source:"chat_matched")
FlowStateStore::clear(conversation)   // task done → notebook empty again
Fixes the old "car not attached" bug. The attach_interest effect always rides along with a booking — it no longer depends on the lead being "qualified" first.
TURN 4 — second appointment, different typeP1: viewing + test-drive can co-exist
CUSTOMERcan I also test drive it on friday 10am?
BOTAll set! 🙌 You now have a viewing tomorrow 2pm AND a test drive Friday 10am for the Atto 3.
FlowGate: empty → CONTINUE→ BookingFlow: type=test_drive ≠ existing viewing→ books a 2nd appointment, mentions both

The conflict check is now keyed on lead + type + pending + future — so a different type never triggers the awkward "you already have an appointment" prompt. NEW

TURN 5 — reschedule, then the capP2: change = 0 confirmations · P3: 3 strikes → human
CUSTOMERmove my viewing to 4pm (…and again… and again)
BOTDone — viewing moved to 4pm. [after the 4th change:] I'll connect you with our team to sort the timing 🙏
AppointmentService::reschedule() — moves the row, no delete appointments.reschedule_count++ (D33-7)

A real counter on the row decides the cap. On the 4th, the flow asks for a handoff — and the new consumer actually makes it happen:

// 4th reschedule → flow returns:
FlowDecision { effects: [{ do: "force_handoff", reason: "reschedule_cap" }], terminal: true }

// D33-10 (NEW): TurnRunner now CONSUMES force_handoff (today it ignores it!)
conversation.handoff_state = "pending"
conversation.handoff_reason = "reschedule_cap"   // reason carried, not hardcoded
notifyStaff();  suppressNormalReply();

5 Who is allowed to do what (the clean split)

JobWho does itStatus
Read the message, pull out intent & entitiesstepUnderstand (the LLM)LIVE
Decide which steps run this turnGraphStepCompiler::walk()LIVE
"Are we mid-task? continue it" — the guardFlowGateNEW
Decide the next step of a task (pure, no writes)BookingFlow.transition() → FlowDecisionNEW
Read/write the open-task memory (the ONLY writer)FlowStateStoreNEW
Actually write to the databaseAppointmentService, LeadVehicleInterestServiceLIVE
Turn facts into a nice reply (phrasing only)stepGenerate (the LLM)LIVE
Catch a lie ("I booked it" when nothing was booked)stepBookingGuardLIVE
One golden rule: the LLM understands and phrases. Code decides actions and does writes. The flow only requests a write; the service performs it. This is why the bot is reproducible and debuggable.

6 The safety rule that protects appointments

The Graph engine is the normal driver. If it ever crashes mid-conversation, the old reliable Orchestrator takes over for that turn (TurnDispatcher::runLiveWithFallback() LIVE). Here's the danger — and the fix.

⚠️ Today's blind cleanup

On a crash, the code wipes the task memory wholesale:

// TurnDispatcher.php:96  (LIVE)
PlaybookCursor::clear(conversation)  // erases everything

Fine today (booking lives in a separate key). But once booking moves into the shared state.flow, a graph hiccup would erase a customer's in-progress appointment. 😱

✅ The fix — selective cleanup

Cleanup becomes picky about what it clears:

// FlowStateStore (NEW, D33-14)
suspendGraphAskOnFallback():
  if flow.kind == "graph_ask":  clear   // graph-only, ok to drop
  if flow.engine_scope == "agnostic": KEEP  // booking/cancel survive!

That's the meaning of engine_scope: agnostic tasks (booking, cancel) survive a crash; graph tasks (a canvas question) don't need to.

7 Why not just put everything in the Graph?

Because the Graph is a per-turn thing — it forgets between messages. The booking rules (how many reschedules, confirm cancel once, attach the car) need to live across messages and must work even when the Graph isn't driving. So:

Graph can…

  • put a book_viewing node in the flow
  • decide order: search first, then offer to book
  • branch on "how many cars matched"

Graph cannot… (the FSM owns this)

  • the reschedule-vs-additional question
  • "confirm cancel exactly once"
  • the 3-strikes reschedule cap
  • attaching the car to the lead
This is exactly why the 4 bugs happened. Those rules live below the Graph, inside the action. The Graph couldn't see or fix them — and they were scattered, so each got patched differently. FlowGate + the FSM put them in one tidy, testable place.

8 30-second cheat sheet

TermPlain meaningReal name
Graphthe per-message router/playbookGraphStepCompiler LIVE
Flow / FSMa multi-message task with rules & statesBookingFlow / CancelFlow NEW
FlowGate"finish the open task before starting new"FlowGate (a pipeline step) NEW
FlowStatethe saved "what task is open" memoryconversations.state.flow NEW
FlowStateStorethe only code that reads/writes that memoryFlowStateStore NEW
transition()"given where we are + this message, what next?" (no writes)Flow.transition() → FlowDecision NEW
effecta requested write; a service actually does itAppointmentService::book() LIVE
engine_scope"agnostic" tasks survive a crash; "graph" tasks don'tfield on state.flow NEW
The whole thing in one sentence: the Graph routes one message; the FSM owns one task across messages; FlowGate makes the bot finish the open task before doing anything new; and FlowStateStore keeps that task's memory in one place so we can always explain — and never accidentally erase — what the bot is doing.