Deriving control state from the log instead of storing it
A colleague steering a live call can pause Connect, take the conversation over, and hand it back. Stored as flags, that sequence produces combinations nothing can interpret and a release that does not know what it is releasing to. supervisor.py stores none of it: the control state is derived from the guidance log, which is ordered, append-only and already the thing being written for other reasons.
The bug a boolean creates#
Two flags look sufficient — one for paused, one for taken over — and they are, right up to the third action. The trouble is that *release* is not a value; it is a return to whatever was true before, and a flag does not carry a before.
| What happened | Flags after it | What is actually true |
|---|---|---|
| Connect is answering | paused=false, taken_over=false | Connect is answering |
| A colleague pauses | paused=true | Connect is silent, the caller is on a live line, nobody is speaking |
| The same colleague takes over | paused=true, taken_over=true | A person is speaking; pause is now meaningless but still set |
| They release | taken_over=false | Does Connect resume, or is it still paused? Both readings are defensible from the row |
| A second colleague releases first | taken_over=false | The row cannot say whose action it recorded, or in which order the two arrived |
Every fix from here makes the row worse: an extra column for the previous state, a nesting depth, a writer identity, a timestamp to order two writes that arrived in the same second. At that point the row is a log with the history removed, which is the point at which keeping the log is cheaper.
Folding the log instead#
The guidance log already exists, because guidance itself has to be recorded: who said what to the call, when, and what happened next. Control verbs ride the same log — engine:hangup, engine:transfer, engine:dtmf, engine:mute — consumed by the worker's poll. The control state is a fold over those entries in order, computed when something asks.
- Ordering is inherent, so two colleagues acting within the same second produce a determinate result rather than a race between two writes to one row.
- Attribution is free: each entry names its author, so "who took this call over" is answerable without a second table.
- Reconstruction is free: the same fold run against the log at any earlier point gives the state at that moment, which is what an argument about a call actually needs.
- There is nothing to migrate when a fifth control verb is added — a new entry kind changes the fold, not the schema.
What the derivation forces you to get right#
A derived state is only as good as the events it folds, so two things become load-bearing that a flag would have let you ignore.
Make every event exactly once.
call_eventsstores each normalised provider event once, with the signature nonce under a partial unique index.Result A carrier that retries a webhook cannot double-apply anything, and a fold over the log is stable under replay. Without that, a duplicate
pausewould nest and a single release would not undo it.Make silence render as nothing at all. A paused or taken-over call replies with empty text and
LISTEN.Result In the carrier's XML dialect an empty speech element is not silence — it is a document that says something, and the caller hears an artefact. Silence has to be the absence of the element.
Keep guidance off the wire. An instruction typed by a colleague goes into the system prompt and nowhere else — not into the spoken reply, not into
call_turns, not onto the line.Result The log can hold blunt internal language safely, which is the only way live steering is usable at all.
Where a stored field is still the right answer#
Deriving everything is the opposite mistake. A call carries both an outcome, which the engine determines, and a human disposition, kept deliberately separate — so a webhook arriving late cannot overwrite what somebody decided about the call. That is not a fold over events; it is one person's assertion, and it belongs in a column of its own.
- Derive
- Control state during a live call, because it is the result of a sequence of actions by several actors.
- Store
- A human disposition, because it is an assertion by one actor that later machine events must not silently change.
- Store separately
- The engine's own outcome, so the two can disagree visibly rather than one quietly winning.
- Never derive
- Anything whose source events are not guaranteed exactly once. Fix the idempotency first, or the fold is a guess.
The cost of the derivation is real but small and bounded: the log for one call is short, and a call has a ceiling of MAX_CALL_SECONDS — 30 minutes — so the fold never grows without limit. UNKNOWN: the per-poll cost of the fold has not been separately profiled, because it has not been the thing worth profiling.
Questions#
Can the caller hear a colleague's guidance?
No. Guidance reaches the model as a system instruction and is never spoken, never written into call_turns, and never placed on the wire. That rule is what makes it safe to type a blunt correction mid-call.
What happens if two colleagues act on the same call at once?
Both actions land in the log in the order they arrived, and the fold applies them in that order. Nothing is lost and nothing races, which is exactly the case a single mutable row handles worst.
Is recomputing the state on every poll wasteful?
It is bounded rather than free. One call's guidance log is short and the call itself is capped at 30 minutes, so the fold is over a small ordered list. The trade bought correctness in the case that was actually breaking things — pause, take over, release, by two people.