# Realtime API state machines — map & re-architecture research Status: research / design (compaction phase). No code changes implied yet. The realtime API (`core/http/endpoints/openai/realtime*.go`) grew feature-by-feature (server_vad → semantic_vad/EOU, streaming pipeline, tool turns, compaction, voice gate, sound detection, WebRTC). The result is several **implicit** state machines whose states and transitions are scattered across goroutine-local variables, shared `Session`/`Conversation` fields under five different mutexes, raw channels, and `context` cancellation. State is *inferred* from variable combinations rather than *stored*; several illegal/inconsistent states are reachable. This document (1) inventories the implicit machines, (2) catalogues the cross-cutting failure modes, (3) researches how to re-implement them explicitly and verifiably, and (4) lists the invariants a correct implementation must guarantee. All line numbers are against the current `feat/realtime-semantic-vad-eou` branch and will drift; treat them as anchors. --- ## Part 1 — Inventory of the implicit state machines There is **no `state`/`status` field anywhere** in `Session` or `Conversation`. Every machine below is reconstructed from variable combinations. ### M1. Connection / transport lifecycle Two transports implement one `Transport` interface; their lifecycles differ sharply. - **WebSocket** (`realtime_transport_ws.go`): essentially stateless — a `*websocket.Conn` plus a write `sync.Mutex`. No send queue, no send goroutine, no closed flag. "Closed" = `ReadEvent` returns an error. - **WebRTC** (`realtime_transport_webrtc.go`): an explicit-ish machine built from raw channels — `dcReady` (closed by `dcDone sync.OnceFunc`), `closed` (closed by `closeDone sync.OnceFunc` from *either* `OnConnectionStateChange` or `Close()`), `flushed`, `sessionCh` (cap 1), `inEvents`/`outEvents` (cap 256), plus a `sendLoop` goroutine and RTP counters under `rtpMu`. Conceptual states (`connecting → data-channel-open → session-created → active → closing → closed`) are **not stored**; the only persisted membership state is the `sessions[sessionID]` map entry (exists `realtime.go:631`→`:1009`). `session-created` and `session-updated` are *events*, not states. Teardown order (`realtime.go:989-1010`): `cancelActiveResponse` → `close(decodeDone)` → `close(done)` (if VAD running) → `close(soundWindowDone)` → `wg.Wait()` → `delete(sessions,…)`. Then, WebRTC only, `defer transport.Close()` → `closeDone()` → `<-flushed` → `pc.Close()`. ### M2. Audio-input / turn-detection (server_vad + semantic_vad + EOU) One `handleVAD` goroutine (`realtime.go:1322`) on a 300 ms ticker. Mode is **re-evaluated every tick** under `sessionLock` (`:1350-1357`) so it can flip mid-turn. - **server_vad** states are encoded by the goroutine-local `speechStarted bool` (`:1337`) plus silence *measured* (not timed) as `audioLength - segEndTime > silenceThreshold` recomputed each tick (`:1461`). States: idle → inspecting → speech-detected → awaiting-commit → committing → transcribing/responding. "Holdback" is a byte count (`noSpeechHoldbackSec*rate*2`), not a timer. - **semantic_vad** adds the `liveTurnState` struct (`realtime_semantic_vad.go`): `live` (nil = closed), `unavailable` (sticky degrade → behaves as server_vad), `eouAtSec`, `parts`, `itemID` (allocated at turn open so captions can stream), `deltasSent`. Extra states: closed, open/streaming-ASR, EOU-pending, EOU-fallback (dynamic silence threshold 0 s when EOU pending, else eagerness 8/4/2 s), retranscribe-gate, EOU-rejected, finished, discarded. The one cross-goroutine edge: the backend recv callback pushes onto `events` (buffered 64, **non-blocking — drops on overflow**, `:116-117`); `drainEvents` reads it on the tick. - **Voice gate** (`realtime_voicegate.go`) runs *inside* the commit goroutine: resolving → authorized/rejected, with a sticky `voiceVerified` (under `gateMu`) for `when:first`. ### M3. Response lifecycle (+ synchronous tool-turn recursion) A response is "active" iff `Session.activeResponseDone` is non-nil and unclosed (`responseMu`, `:172`). One goroutine owns it; its lifetime == that channel's. State is observable only through the `response.*` event stream and `ItemStatus*` on the assistant item. Logical states: idle → starting → generating-text → generating-audio → tool-call-pending → tool-executing → awaiting-next-tool-turn → cancelling → done(completed|cancelled) | failed. - Cancellation is **cooperative at discrete checkpoints** (`ctx.Err()` at `:2172,2364,2394`, `realtime_stream.go:193,202,241,259`). - The tool loop is **synchronous recursion on the same goroutine**, bounded by `maxAssistantToolTurns = 10`; each level mints a fresh `responseID` and emits a full `response.created … response.done{Completed}` cycle — so one user turn can emit *several* `response.done{Completed}` events under different IDs. - Terminal events are **not exactly-once**: failed paths `return` with no `response.done`; cancelled paths emit `done{Cancelled}`; the completed terminal is unconditional at the tail of `emitToolCallItems`. ### M4. Conversation / compaction `Conversation`: `Items` + `Memory` (rolling summary) under `Lock`; `compacting atomic.Bool`. States: normal ↔ compacting. Compaction (`realtime_compaction.go`) snapshots overflow under `Lock`, summarizes **unlocked**, re-locks and commits guarded by an optimistic head-`prefixMatches` check. It is launched **only by turn-0 `triggerResponse`** (`:1963`), off the response path — so a long agentic turn (recursion calls `triggerResponseAtTurn` directly) can append many tool items and **never compact** until the next user turn (compaction starvation). ### M5. Streaming sub-machines (transcription, chunker, TTS) Backend LLM/TTS/transcription streams are **synchronous callback recv loops on the caller's goroutine** — no internal goroutines/channels. The only true concurrent FSM is: - **TTS pipeline** (`realtime_tts_pipeline.go`): one worker goroutine, an **unbounded** mutex-guarded `queue`, a coalesced `wake` chan (cap 1), a `closed` flag, a `done` chan closed once by the worker's `defer`, a lock-free `failed atomic.Bool`, and worker-owned `audio`/`firstErr` that are safe to read only after `wait()` joins via `done`. Idempotent `wait()`; deferred `wait()` backstop guarantees no worker leak. - **Chunker** (`realtime_chunker.go`): a pure single-buffer FSM (buffering ↔ emitting, `flush` = hard boundary). **No concurrency guard** — correctness depends entirely on `push`/`flush` being called from one goroutine (the LLM recv loop). On cancel the flush is skipped, so the buffered partial clause is intentionally dropped. - **Transcription** (`realtime_transcription.go`): stateless straight-line function; "streaming" is just repeated synchronous callbacks. --- ## Part 2 — Cross-cutting failure modes (why it's a mess) 1. **Shared mutable `Session` config with inconsistent locking (the core problem).** `updateSession`/`updateTransSession` mutate `Voice`, `Instructions`, `Tools`, `OutputModalities`, `ModelConfig`, **`ModelInterface`**, sample rates, and the shared `InputAudioTranscription` pointer under `sessionLock`. But in-flight response/speech/transcription goroutines read those same fields **without any lock** (`realtime_speech.go:72-79`, `realtime_stream.go:228`, semantic_vad `:110`). Reloading `ModelInterface` mid-response is a data race against a running Predict/TTS/Transcribe, and the swapped-out model is dropped without Close. `sessionLock` actually guards the *global `sessions` map*; it only mutually excludes the handful of other sites that happen to also take it (handleVAD tick, the commit branch). Response goroutines never take it. 2. **Two writers of the active-response pair.** `startResponse`/`cancelActiveResponse` are called from both the main read loop (`:836,973,981,990`) **and** the VAD goroutine (barge-in `:1429`, end-of-speech `:1543`). `responseMu` guards only the field swap; the `<-done` wait is outside the lock. A read-loop `ResponseCreate` racing a VAD `speech_stopped` can have both read the same prior pair, both overwrite, and briefly leave **two live response goroutines** both appending to `conv.Items`. The "never overlapping" guarantee holds only under the unstated assumption that responses are driven from a single goroutine — which is false. 3. **State is inferred, not stored.** Whether a response is active, whether a turn is open, whether audio is being buffered — all are derived from combinations of booleans, nil-checks, channel state, and `context` error. No single source of truth; no place to assert an invariant. 4. **Reachable inconsistent states.** e.g. after a semantic-VAD `discardTurn`, `speechStarted` stays true while `lts` is closed, so they disagree and the next onset suppresses `SpeechStarted`. Mid-stream cancel leaves the client having seen `output_item.added`/`content_part.added` with no matching `…done`. `events`-channel overflow silently drops an EOU, degrading EOU-pending to the 2–8 s fallback. 5. **Lifecycle/ownership gaps.** `decodeOpusLoop` is a bare `go` (not in `wg`) and can run after `delete(sessions,…)`. `handleIncomingAudioTrack` (pion `OnTrack` goroutine) has **no shutdown signal** — it appends to `OpusFrames` until `ReadRTP` errors, unjoined by `wg`. WebRTC `outEvents` enqueued before the DC opens are lost on early failure. 6. **The `done`-channel/`vadServerStarted` toggle dance.** A single `done` local (`:655`) is reassigned to a fresh channel on each VAD start (`:662`) and closed at toggle-off (`:670`) and teardown (`:999`). Safe today only because one goroutine owns it — one variable name meaning different channels over time is a structural fragility, not an explicit lifecycle. --- ## Part 3 — Research: explicit, verifiable re-implementation The goal the user stated: **transitions cannot lead to an inconsistent state, and we can verify that.** Four layered techniques, from architecture down to runtime. ### 3.1 Architecture: single-writer session actor (share by communicating) The root cause of (1) and (2) is *shared mutable state across goroutines*. The most effective, idiomatic-Go fix is to give each session **one owning goroutine** that holds all session state with **no locks**, and have every other goroutine communicate with it over channels: ``` ┌────────── inbound events ──────────┐ transport ─┤ client events (ReadEvent) │ VAD ─┤ vad: speech_started/stopped, EOU ├─► session actor ──► outbound model I/O ─┤ llm/tts/asr results, errors │ (owns ALL state, events timers ─┤ ticks, deadlines │ single goroutine) └────────────────────────────────────┘ ``` - All state mutation happens in one place; `sessionLock`, `responseMu`, `gateMu`, `AudioBufferLock`, `OpusFramesLock`, `Conversation.Lock` collapse into "the actor owns it." Worker goroutines (Predict/TTS/ASR, opus decode, RTP read) become **stateless effects** that take an immutable snapshot in and send results back as events. - `ModelInterface` reload becomes an event the actor sequences relative to responses (e.g. drain/cancel the active response first), eliminating the mid-call swap race. - Cancellation stays `context`-based but the actor is the only thing that starts/stops responses, killing the dual-writer race (2). This is the actor / CSP model. It does not by itself prove correctness — that's what 3.2–3.4 add — but it makes the state *centralized and explicit*, which is the precondition for verification. ### 3.2 Make illegal states unrepresentable (type-level) Inside the actor, model each machine as an explicit state with a **pure transition function** `next(state, event) (state, []effect, error)`: - Represent states as a Go **sealed sum type** (interface with an unexported marker method, one struct per state carrying only that state's data) so e.g. `EOU-pending` data cannot be accessed while `Closed`. This is the Go equivalent of an ADT and is the single biggest lever for "inconsistent state unrepresentable." - The transition function is **total and pure** (no I/O, no goroutines): it returns the next state plus a list of *effects* (send event, start Predict, arm timer) that the actor executes. Pure transition functions are trivially unit-testable and property-testable. - An unexpected `(state, event)` pair returns an explicit error / stays put and logs — never a silent half-transition. The four machines are **hierarchical** (a statechart): Connection ⊃ Turn(M2) and Response(M3) ⊃ Tool-turn; Conversation(M4) and the TTS sub-machine(M5) are largely orthogonal regions. Model them as nested states rather than one flat enum. Library options (all guard *logic*, none give concurrency safety — that's 3.1's job): - `qmuntal/stateless` — declarative, hierarchical, guard/entry/exit actions; closest fit. - `looplab/fsm` — simpler, flat, event-callback based. - Hand-rolled transition tables — most control, no dep; recommended here given the hierarchy and the desire to keep transitions auditable. `go.mod` currently pulls no FSM lib. ### 3.3 Design-time formal verification (prove the protocol) Before/while coding, model the *protocol* (not the Go) in a model checker to prove the hard concurrency properties exhaustively: - **FizzBee** (the adopted tool) to specify the actor's event/state space and check: no two concurrent active responses; barge-in + ResponseCancel + speech_stopped interleavings never deadlock or drop a turn; every `response.created` is eventually followed by exactly one terminal; teardown joins all goroutines. The cancel/startResponse/barge-in interplay (failure mode 2) is exactly the kind of liveness/safety property model checkers exist for. - Keep the spec small and focused on the M2↔M3 boundary (turn detection ↔ response), which is where the real races live. ### 3.4 Implementation-time & runtime verification - **Exhaustive table-driven transition tests**: since transitions are a pure function, enumerate `(state × event)` and assert the result for every cell, including the illegal cells (assert they error / no-op). This is the practical stand-in for a proof that "no transition leads to inconsistent state." - **Property-based testing**: feed random event sequences into the actor and assert global invariants hold after every step (Part 4). This catches reachable-bad-state bugs the example tests miss. (Implemented as Ginkgo/Gomega seeded random-walk specs — see Part 6.2 for why not `rapid`.) - **Race detector under load**: run the property tests with `-race`; with 3.1 there should be *zero* shared mutable state, so `-race` cleanliness becomes a meaningful signal rather than noise. - **Runtime invariant assertions + structured transition logging**: log every `state --event--> state` with the session ID; assert invariants in dev builds. Replace today's silent degradations (dropped EOU, suppressed SpeechStarted) with explicit, observable transitions. ### 3.5 Recommended path for LocalAI 1. Specify the M2↔M3 protocol in FizzBee; nail the cancel/barge-in invariants. 2. Introduce a per-session actor (3.1) that owns existing state behind the current `Transport` interface — incremental, keeps the event types. 3. Replace each implicit machine with an explicit sealed-state transition function (3.2), one at a time: Response first (highest-risk dual-writer), then Turn/VAD, then Connection, then leave TTS/Chunker/Compaction (already mostly self-contained) for last. 4. Land the table-driven + property-based test suites alongside each machine; gate on `-race`. --- ## Part 4 — Invariants a correct implementation must guarantee These are the "cannot reach inconsistent state" properties to encode as assertions, property-test oracles, and FizzBee invariants: 1. **At most one active response per session** at any instant (no overlapping response goroutines; no two appenders to `conv.Items` from response logic). 2. **Exactly one terminal per `response.created`**: every emitted `response.created` is followed by exactly one of `response.done{completed|cancelled}` or a defined failure terminal — never zero, never two. (Decide whether agentic tool turns are one response or many; make it explicit either way.) 3. **No `response.*` content events after that response's terminal.** No `output_item.added`/`content_part.added` without a matching `…done` (even on cancel). 4. **Turn/response coupling**: `speechStarted` ⟺ a live turn is open; barge-in cancels the active response *before* a new turn's commit starts. 5. **No config field is read by a worker while being mutated** (reload is sequenced against in-flight work; a response uses an immutable snapshot of model/voice/tools). 6. **Audio buffer monotonic & consistent**: commit/clear/append/VAD-drop never lose or double-consume bytes; `clear` resets *all* turn state (including `lts`). 7. **No dropped control events**: an EOU/Final is never silently lost (no overflow-drop on a bounded channel that changes turn outcome). 8. **Clean teardown**: every spawned goroutine (incl. `decodeOpusLoop`, `handleIncomingAudioTrack`) is signalled and joined before the session is deleted; no sends after transport close. 9. **Compaction safety & liveness**: compaction never races a reader into a torn `Items`; and it actually runs when the trigger is exceeded, including inside long agentic turns. 10. **Idempotent close**: every channel/resource closed exactly once on every path. --- ## Implementation status - **M3 (response coordination) — first vertical slice landed.** Explicit machine in `core/http/endpoints/openai/respcoord/` (sealed `State`/`Event`/`Effect` sum types, a total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega seeded-property + concurrent conformance tests (green under `-race`); a deterministic characterization test pinning the legacy dual-writer race. Authoritative spec: `formal-verification/response_lifecycle.fizz`. Gate: `scripts/realtime-conformance.sh` (Go layer always; FizzBee when pinned) wired as `make test-realtime-conformance` and `.github/workflows/realtime-conformance.yml`. See `formal-verification/README.md`. - **Gate is fail-closed and pinned (done).** `fizzbee.sha256` pins all four platforms; the gate hard-fails without FizzBee; CI installs+caches the verified binary with no skip; pre-commit runs the gate on `respcoord/**` or `formal-verification/**` changes. - **M3 wired into the live session (done).** `realtime_respcoord.go` adds `responseSink` (the `respcoord.Coordinator` + a goroutine-spawning effect sink) to `Session`. The legacy `startResponse`/`cancelActiveResponse` and the dual-writer `activeResponse*`/`responseMu` fields are gone; all six call sites (manual commit, `response.create`, VAD speech-stopped, `response.cancel`, barge-in, teardown) route through it. Barge-in/cancel are now non-blocking (removes the legacy ~300 ms VAD stall); teardown stops input goroutines, then cancels + `wait()`s all response goroutines before deleting the session. `EmitTerminal` is a no-op for now (the response body still emits its own `response.done`) — coordination is fixed without changing wire behavior. Verified: builds, `go vet` clean, all 300 openai specs pass under `-race`, and `make test-realtime` (the mock-backend realtime e2e suite, 12 specs over WS + WebRTC) passes. - **Single authoritative terminal + populated Output/Usage (done).** One `response.created` and one `response.done` per `response.create`, even across the server-side agentic tool loop (which is now internal turns of one response, not one terminal each). A `liveResponse` accumulator threads through `triggerResponse`→`triggerResponseAtTurn`→`emitToolCallItems`/`streamLLMResponse`, collecting output items as they complete and summing token usage; `triggerResponse` emits the one terminal (completed/cancelled; failed still emits none, matching legacy) with `Output` + `Usage` filled in (both were always empty before). Verified: 301 openai specs under `-race` (incl. a new `triggerResponse` terminal test) + `make test-realtime`. Design note: emission is hoisted to `triggerResponse` (the body owns it) rather than the coordinator's `EmitTerminal` effect — at cancel/supersede time the coordinator doesn't yet have the body's partial Output, so the body, which does, is the natural emitter. The coordinator still guarantees one body run per `response.create`, so "exactly one terminal" holds transitively; `EmitTerminal` remains the spec's logical marker (no-op in the sink). - **M2 (turn detection) — model + spec landed AND wired into the live session.** Explicit machine in `core/http/endpoints/openai/turncoord/` (sealed `State` = `Idle | Speaking{Turn}`, `Event` = `Onset | Silence | Abort{Reason}`, `Effect` = `BargeIn | OpenTurn | EmitSpeechStarted | EmitSpeechStopped | CommitTurn | DiscardTurn`, a total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega seeded-property + concurrent conformance tests (green under `-race`). The fix it encodes: "speech detected" and "a turn is open" — the two legacy variables (`speechStarted` and `lts.open()`) that a `discardTurn` could desync (failure mode 4) — become ONE state, so the next-onset suppression bug is unrepresentable. Authoritative spec: `formal-verification/turn_lifecycle.fizz`, with an `always assertion Coupled` (speech ⟺ turn-open), verified non-vacuous (deleting `self.speech = 0` in `Abort` makes the checker report `Coupled` violated). The gate (`scripts/realtime-conformance.sh`, pre-commit, CI) covers `turncoord` and the spec. **Wired (done):** `realtime_turncoord.go` adds `turnSink` (the `turncoord.Coordinator` + a loop-local effect sink) to `handleVAD`. The legacy `speechStarted` bool is gone; onset/no-speech-clear/commit/teardown route through `coord.Apply(Onset|Abort{NoSpeech}|Silence|Abort{Teardown})`. The turn id is minted at onset and carried by the coordinator to the committed event (so it matches the live captions); `liveTurnState.openTurn` now takes that id instead of minting its own. A semantic→server mode switch mid-turn is deliberately NOT an abort (it only drops the orphaned live stream and lets the turn continue under server_vad), so it stays inline. Verified: builds, `go vet`/`gofmt`/golangci-lint clean, all openai specs under `-race`, and `make test-realtime` (12 e2e specs over WS + WebRTC) pass. - **M1 (connection lifecycle) — model + spec landed AND wired.** Explicit machine in `core/http/endpoints/openai/conncoord/` (sealed `State` = `Live{VADRunning} | Torn`, `Event` = `SetVAD | Close`, `Effect` = `StartVAD | StopVAD | Teardown`, a total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega seeded-property + concurrent conformance tests (green under `-race`). It replaces the legacy `vadServerStarted` bool + the `done` channel reassigned on every turn-detection toggle and closed from two sites (failure mode 6): the coordinator owns whether the VAD goroutine runs, so its done channel is closed exactly once and never resurrected after teardown; `Close` moves to `Torn`, which absorbs every later event so teardown runs exactly once even from multiple exit paths (invariants #8, #10). Spec: `formal-verification/conn_lifecycle.fizz` (`always assertion TeardownOnce` + `NoRunAfterTorn`), verified non-vacuous (deleting `self.torn = 1` in `Close` fails `TeardownOnce`). **Wired (done):** `realtime_conncoord.go` adds `connSink`; the handler's setup/`toggleVAD`/teardown now route through `conn.setVAD(...)`/`conn.close()`; the `done`/`vadServerStarted` locals and the manual ordered-teardown block are gone (the Teardown effect performs that sequence). Verified: builds, vet/gofmt/golangci-lint clean, openai specs under `-race`, `make test-realtime` (12 e2e WS+WebRTC), full conformance gate green (3 Go packages + 3 fizz specs PASSED). - **M4 (conversation compaction) — model + spec landed AND wired.** Explicit machine in `core/http/endpoints/openai/compactcoord/` (sealed `State` = `Idle | Running`, `Event` = `Trigger | Finished`, `Effect` = `StartCompaction`, a total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega seeded-property + concurrent (effect-spawns-work-reports-Finished) conformance tests (green under `-race`). It makes the legacy `compacting atomic.Bool` single-flight guard explicit: a `Trigger` while `Running` is dropped (not superseded — compaction is idempotent work on the same overflow), so at most one summarize+evict runs per conversation (invariant #9). Spec: `formal-verification/compaction.fizz` (`always assertion SingleFlight`), verified non-vacuous (deleting the `if self.active == 0` guard fails `SingleFlight`). **Wired (done):** `realtime_compactcoord.go` adds `compactionSink`; the `Conversation.compacting atomic.Bool` is replaced by `Conversation.compaction *compactionSink` (built at conversation creation with the summarize+evict run closure); `maybeCompact` now calls `conv.compaction.trigger()`. The summarizer resolution + `compact()` stay in the sink's spawned goroutine (off the response path); `compact()` itself (snapshot/summarize-unlocked/optimistic-commit) is unchanged. Verified: builds, vet/gofmt/golangci-lint clean, openai specs under `-race`, `make test-realtime` (12 e2e), full conformance gate green (4 Go packages + 4 fizz specs PASSED). - **M5 (TTS pipeline lifecycle) — model + spec landed AND wired.** Explicit machine in `core/http/endpoints/openai/ttscoord/` (sealed `State` = `Open | Closing | Closed`, `Event` = `Close | WorkerExited`, `Effect` = `Wake`, a total pure `Next`, a single-writer `Coordinator`); transition-table + Ginkgo/Gomega seeded-property + two-writer conformance tests (green under `-race`). It is a genuine two-writer machine (producer `Close` from `wait()` vs worker `WorkerExited`); it makes the legacy `closed bool` lifecycle explicit and monotonic, fixes the latent enqueue-after-close silent drop (enqueue is now gated on `Open`), and guarantees idempotent `wait()` (one wake / one worker join). The poison `failed` latch stays a lock-free `atomic.Bool` (orthogonal, read per clause on the worker's hot path). Spec: `formal-verification/tts_pipeline.fizz` (`always assertion WakeOnce` + `Monotonic`), verified non-vacuous (deleting the `if self.phase == 0` guard in `Close` fails `WakeOnce`). **Wired (done):** `realtime_tts_pipeline.go`'s `ttsPipeline` embeds the coordinator (and is its effect sink — `Wake` → `signal()`); `closed bool` is gone; the worker checks `closing()` and raises `WorkerExited` on drain, `enqueue` rejects once not `Open`, `wait()` raises `Close`. The wake/done channel mechanics are unchanged. Verified: builds, vet/gofmt/golangci-lint clean, openai specs under `-race`, `make test-realtime` (12 e2e), full conformance gate green (5 Go packages + 5 fizz specs PASSED). - **All five mapped machines (M1–M5) are now explicit, wired, and verified.** The realtime-conformance gate model-checks all `.fizz` specs and runs all five Go conformance suites under `-race`, fail-closed. - **The machines form a hierarchy, and that relationship is now modeled and enforced.** M1 (connection) is the parent region; when it tears down, every child must be terminal. Previously this was only an imperative side effect of `conncoord`'s teardown ordering, with a real gap (M4 compaction was fire-and-forget and could outlive the torn session). Now: - `formal-verification/session_lifecycle.fizz` is a **composition spec** that models conn + its direct children (vad/M2, resp/M3, compaction/M4) as one statechart and asserts `ChildrenDieWithParent` (conn torn ⟹ all children terminal) plus "no child starts after teardown". Its non-vacuity reproduces the exact M4 gap (drop the compaction-terminate line → assertion fails). - `respcoord` (M3) and `compactcoord` (M4) gained an absorbing **`Terminated`** state + a `Shutdown` event, so a response/compaction cannot start after teardown (structural "no resurrection"). - `conncoord`'s `Teardown` effect now explicitly drives the children terminal: stop+join the VAD goroutine (M2), `respSink.shutdown()` (M3 → Terminated, joins response goroutines and their M5 pipelines), and `compaction.shutdown()` for every conversation (M4: cancel the in-flight summary via a session-scoped context, then join — **closing the gap**). `compact` now takes a `context` so teardown can bound the join. M2's terminal is realized by the goroutine join and M5's by its existing `Closed`; the persistent coordinators (M3/M4) carry the explicit `Terminated` state. ## Part 5 — Library vs hand-rolled (Go ecosystem, verified 2026-06) Researched against live GitHub/pkg.go.dev data. **Verdict: hand-roll a typed transition table over sealed sum-type states for the per-connection machines.** No Go library gives the two properties we most want — *compile-time-illegal states* and a *pure `next(state,event)->(state,[]effect,error)`*; every library models states as `string`/`int`/`any` and fires side-effecting callbacks mid-transition. And since the actor (Part 3.1) drives everything from one goroutine, the libraries' main value-add — internal locking — is dead weight. Library landscape: | Option | Stars / status | Hierarchy | Typed states | Illegal-transition | Viz | Fit | |---|---|---|---|---|---|---| | **hand-rolled table + sealed sum types** | — | DIY (parent field / nested switch) | **yes** (sealed iface) | explicit `default:` | ~30 LOC Mermaid emitter | **best** | | **qmuntal/stateless** (port of .NET Stateless) | 1.36k, v1.8.0 2026-02, maintained | yes (substates, guards, entry/exit, internal/ignored) | `any` | `error` + `OnUnhandledTrigger` + `PermittedTriggers` | DOT | best library fallback if hierarchy grows | | **looplab/fsm** | 3.4k, v1.0.3 2025-05, maintained | flat | strings | typed errors | **DOT+Mermaid** | only for flat machines wanting free diagrams | | cocoonspace/fsm | 89, dormant 2021 | flat | int | `bool` no-op | — | lock-free but dead; DIY beats it | | true Harel statecharts (gstate, statechartx) | ≤10, <1yr, single-author | parallel+history | varies | varies | varies | only if we truly need parallel regions; unproven | | Temporal / Cadence | large, maintained | n/a | n/a | n/a | n/a | **overkill** — external cluster+DB, durable replay, wrong latency class | Decision: hand-roll; keep **qmuntal/stateless** as the fallback if one machine grows deep hierarchy/guards faster than we want to hand-maintain (its `error`-on-illegal-trigger and `PermittedTriggers()` are the most useful library features for our "reject illegal transitions" requirement, at the cost of `any`-typed states). Add a tiny Mermaid emitter over the hand-rolled table so we keep the visualization the libraries advertise. ## Part 6 — Formal design tied to code, and making it authoritative The user requirement: the formal design is **authoritative** — a coding agent should be unable to silently change implementation behavior without it being caught against the spec; the default path is "update the spec and re-verify," not "edit the code and ignore the spec." This is a *conformance + enforcement* problem, in three layers. ### 6.1 The source of truth & design-time check Write the concurrency-critical core — the **M2↔M3 boundary** (turn detection ↔ response: barge-in, ResponseCancel, speech_stopped, the dual-writer race) — as a **FizzBee** spec and **model-check it in CI**. Keep the spec small and focused on M2↔M3; that is where the real safety/liveness properties (Part 4 invariants 1–4) live. (FizzBee is the adopted model checker — see Part 6.4.) ### 6.2 The conformance bridge (code ↔ spec) The honest finding: design-time model checking is well-supported; the *Go conformance bridge is thin everywhere* and needs per-spec glue. Two layers, adopted together: 1. **FizzBee MBT** — the authoritative layer. The `.fizz` spec is model-checked, and `fizz mbt-scaffold --lang go` generates Go interfaces + a `go test` harness; you implement adapters mapping model actions→code and `StateGetter`→state. Conformance runs as plain `go test` — the cleanest CI fit. Risk: pre-1.0, essentially one maintainer (pin a version + sha256, vendor examples). 2. **Ginkgo/Gomega seeded property tests** — the Go-native floor. A small Go model (the test's `open`/`registered` shadow) is the oracle; a fixed-seed random walk drives random event sequences against the `Coordinator`, asserting the Part-4 invariants after each step / per seed. It checks the *implementation* against a Go oracle — it complements, but does not replace, the FizzBee check of the *design*. (We originally specced `pgregory.net/rapid` here for its `(*T).Repeat` driver and automatic shrinking, but LocalAI mandates Ginkgo/Gomega for all tests — its `forbidigo` lint forbids stdlib `testing` assertions — and `rapid.Check` needs a concrete `*testing.T`/`*rapid.T` that cannot run inside a Ginkgo `It`. Rather than weaken the lint gate with an exclusion, the property layer is hand-rolled seeded walks: fixed seeds make every failure reproducible, at the cost of `rapid`'s automatic shrinking. `rapid` is consequently not a direct dependency.) These compose: model-check the design (6.1) for "the design is right"; conformance-test the code (6.2) for "the code matches the design." Add `go test -race` (with `-cpu=1,2,4`, repeated runs) over the stateful tests for interleaving-bug discovery, and Go native fuzzing over the *same* harness for coverage-guided sequence exploration + a committable regression corpus. (`testing/quick` is frozen — do not use.) There is no viable single-source-of-truth codegen (one spec compiled into both the runtime Go and the model) for retrofitting existing Go — the candidates are research-grade and greenfield-only. Our practical substitute is the CI gate below plus a single Go transition table that emits both the diagram and the test action set. ### 6.3 Enforcement — making the design un-ignorable for agents Structural enforcement, leveraging this repo's existing non-bypassable gate culture (pre-commit + monotonic ratchets; `--no-verify` is forbidden, baselines never lowered): 1. **Add a `realtime-conformance` gate** to the pre-commit/CI pipeline that runs (a) the model check (6.1) and (b) the conformance bridge (6.2). A behavior change that does not conform turns the gate **red**; the only green paths are *make the code conform* or *update the spec* — and updating the spec re-triggers the model check, so an illegal design is rejected too. This is the actual mechanism that makes "update the design and verify" the default rather than optional. 2. **Treat the spec as a ratchet artifact** like coverage: the gate must not be weakened, the spec not deleted, the build tag not silently disabled. 3. **Write an `.agents/realtime-state-machines.md` guide** (indexed from `CLAUDE.md`) stating the spec is the source of truth: change the spec first, re-run the gate, then implement. The doc is secondary; the gate is what enforces it. ### 6.4 Decided stack - **Implementation:** hand-rolled sealed-state transition functions + single-writer actor (Parts 3.1–3.2). - **Design-time + conformance:** **FizzBee** (decided). `.fizz` spec is model-checked, and `fizz`'s Go MBT generator (`mbt/generator/templates/go` → interfaces/adapters/test; driven via a gRPC plugin in `mbt/lib/go`) produces a `go test` conformance harness whose adapters map model actions → our actor and `StateGetter` → our state. Go is a first-class MBT target (Go + Rust are the only two). Verified 2026-06: Apache-2.0, v0.5.2, prebuilt linux/macos×x86/arm binaries, ships Claude Code skills (`/fizz-spec|check|debug|mbt`) for the spec-authoring loop. - **Go-native layer:** **Ginkgo/Gomega seeded property tests** run alongside — they check the *implementation*, complementing (not substituting for) the FizzBee check of the *design*. Skipping FizzBee is NOT "degrading to the Go layer": the design authority would be gone. The gate is therefore **fail-closed** (see Enforcement). (Originally specced as `rapid`; switched to Ginkgo/Gomega to satisfy LocalAI's Ginkgo-only `forbidigo` lint without weakening that gate — see Part 6.2.) - **Enforcement:** the `realtime-conformance` pre-commit/CI gate + `.agents/` guide (Part 6.3). FizzBee risk mitigations (decided): - The gate is **fail-closed**: a missing FizzBee is a hard failure, never a silent skip. The only bypass is the explicit, loud `REALTIME_CONFORMANCE_SKIP_FIZZBEE=1` (local only; CI never sets it; pre-commit runs the gate on any `respcoord/**` or `formal-verification/**` change so a pure `.fizz` edit still re-verifies). - CI **pins the FizzBee release binary by version + sha256** (`formal-verification/fizzbee.sha256`, all four platforms, digests from the GitHub release; installer verifies before extract, CI caches it). Not go-gettable: `pkg/modelchecker` imports the Bazel-internal `fizz/proto` with no committed `.pb.go`, so a plain `go get` won't build — hence the pinned binary. - Keep the `.fizz` model **portable** (no exotic features) so it stays re-expressible in another model checker if FizzBee is ever abandoned — lock-in is at the tooling layer only, not the design. ## Open questions (decide before implementing) - **Scope of the actor refactor**: full single-writer per session, or incrementally migrate one machine at a time behind the existing locks? (Suggest: M3 response coordination first — it has the load-bearing dual-writer bug.) Resolved: **FSM library vs hand-rolled** → hand-rolled sealed-state tables, qmuntal/stateless fallback (Part 5). **Conformance bridge** → FizzBee (model-check + Go MBT) with a Ginkgo/Gomega seeded-property Go-native floor as hedge (Part 6.4). **Single-source-of-truth codegen** (PGo/MPCal) → not viable (research-grade, greenfield-only); substitute is the CI conformance gate (Part 6.3). **Agentic turn semantics** → invariant #2 is **one `response.done` per `response.create`** (OpenAI-faithful); the server-side `AssistantExecutor` tool loop becomes internal sub-states of a single response rather than emitting one terminal per turn. Verified safe in-tree: the current `response.done` carries only `{id, object, status}` (`Output`/`Usage` never populated), the React UI (`Talk.jsx:330`) reads only `status`, every unit test already asserts `ResponseDone == 1` for tool turns, no test expects multiplicity, and the server-side recursion is untested. Collapsing also fixes a latent "Listening…" flicker mid-agentic-loop. The client-driven tool loop (fresh `response.create` per round-trip) legitimately keeps one terminal each — unaffected. Follow-up: actually populate `Output` + `Usage` in the single terminal (currently always empty).