The Conversation Is a Lane
Accepted external events are kept in an ordered lane keyed by the full id_card:
tenant, project, user, conversation, and agent. A turn is a consumer that briefly owns that lane. The hard
part is not delivery — it is ownership when turns overlap, and guaranteeing a stale turn can
never commit. This piece walks the path from client action to folded block through the separations that
make it work: wake vs event body,
handler vs consumer, and bus event vs timeline block.
A conversation looks like a request and a reply. Underneath, accepted external events are
kept in an ordered event lane. The actual lane key is the full id_card — tenant,
project, user, conversation, and agent — and a turn is a consumer that briefly owns that lane. A typed
prompt, a file dropped on a widget, a webhook, or a mid-flight "stop" can all enter through the same
protocol and are read in the sequence assigned by that lane.
That reframe is the whole design. Once a conversation is a lane instead of a call, the hard engineering is no longer delivery — Redis delivers. It is ownership: when a new event arrives while a turn is already running, or a worker dies mid-turn, or two turns race for the same conversation — which turn owns the lane, and how do we guarantee a stale turn can never commit its answer? The event bus is built almost entirely around answering that.
This article walks the path from a client action to a folded ReAct block, and dwells on the three things that make it more than a message queue: the split between a wake and the event body; the handler/consumer ownership protocol with its supersession fences; and the gate that keeps a bus event distinct from a timeline block — including the live control events (steer, followup) that ride the same lane.
id_card = one ordered conversation/agent lane. The ReAct timeline is one consumer
outcome of that lane, not the definition of the bus.One lane per id_card
Every accepted event belongs to exactly one lane, named by a composite identity — the id_card:
tenant + project + user_id + conversation_id + agent_id
When the client names no agent, the platform fills in default.react.agent. That last coordinate
matters: two agents in the same conversation are two lanes. The lane is the unit of ordering and the unit of
ownership; nothing below it interleaves.
Identity on the lane is deliberately layered, so ordering, occurrence, and meaning never get confused:
id_card which lane + timeline scope (tenant/project/user/conv/agent)
event_source_id the semantic source + policy key
event_id one accepted occurrence (assigned by the lane)
sequence monotonic order in the lane (assigned by Redis)
For one id_card, the Redis lane sequence is the event order — full
stop. The processor's ready queue is a wake-up channel and must never be treated as the ordering source. This
separation is the first load-bearing idea, and the next section is its consequence.
The wake is not the event
Ingress does one subtle thing that shapes everything downstream: it splits "there is work" from
"the work." When a client submits an external_events[] batch, ingress validates
auth/session, resolves the lane identity, normalizes event ids and timestamps, then admits the batch according
to its reactivity:
L and one wake pointer to
Q. Non-reactive admission writes only to L.For a reactive batch, publication of all prepared lane records and admission of one processor wake are
atomic: if wake admission fails, none of that batch is made visible in the lane. A non-reactive batch is
published only to L and does not schedule a turn. The wake is an
ExternalEventLaneWakeup: service/routing/user context plus the lane coordinates to find the
occurrence — but it intentionally contains no request body. Content is recovered later from the
accepted event's stored task_payload.
There is one event protocol, ExternalEventPayload; for lane-backed reactive work the queue carries
only the wake pointer. A wake can be stale by the time it is dequeued because a running owner may
already have consumed past it. Keeping the body in L lets the processor decide from current lane
state. At turn close, ContextBrowser can enqueue a fresh post_save_handoff wake for
retained unconsumed reactive work; it still does not copy the event body into the queue.
Two roles: handler and consumer
Ownership is split into two roles that are easy to conflate and must be kept apart:
Handler the TURN that currently owns the lane — logical ownership, not a process
Consumer the loop that reads lane events and folds them — the ContextBrowser event reader
The handler is a claim in a shared state table T — "turn-A owns this lane."
The consumer is the live reader loop inside the running turn. The state table holds both, as flat
fields:
T.handler_turn_id
T.handler_status open | closed
T.handler_status_at
T.consumer_status active | scheduled | none
T.consumer_status_at
T.last_processed_event_timestamp
T.last_processed_event_id
T.last_processed_reactive_event_timestamp
Here is the trap the design steps around: handler_status_at is not liveness. It only
records when handler state was last written. A handler can read open forever after its worker
has died. The real liveness signal is a fresh consumer_status_at — a heartbeat the active
reader refreshes only while it still matches the current handler.
T.last_processed_event_id disambiguates events that share a timestamp. Liveness lives with the
consumer, ownership lives with the handler, and the whole recovery protocol turns on not mistaking one for the
other.
The live ContextBrowser reader also holds a separate token-fenced Redis
event-source owner lease. It acquires the lease when the listener starts, refreshes it while
reading, and releases it when the listener stops. That lease fences the reader; the handler claim and fresh
active consumer heartbeat remain the turn-ownership and reclaim protocol.
- Fresh active
consumer_status_at→ do not steal the handler; a live owner is folding. - Stale or missing
consumer_status_at→ reclaim the lane for a new turn. - Fresh scheduled
consumer_status_at→ don't start a duplicate wake — but this is not owner liveness at handler open.
Four ways to answer a wake
When the processor dequeues a wake, it does not immediately start a turn. It consults
T and picks exactly one of four outcomes. This little decision is where duplicate turns, thundering
wakes, and races get filtered out:
- Ignore — the wake points at reactive work whose timestamp is already past the processed cursor. The wake itself is obsolete; the lane already proves the work was handled.
- Defer to active — a live consumer heartbeat is fresh; starting another turn
would create a competing owner. The event stays in
L; the live owner will read it. - Defer duplicate scheduled — proc just reserved a start for this lane. During app load / entrypoint handoff, a second wake must not spawn a second starter.
- Schedule — nothing fresh covers the lane, so proc marks
T.consumer = scheduledand begins loading the app.
The distinction in one line: "ignore" means the wake is obsolete; "defer" means the wake is still valid but someone already has it covered.
The fence chain
Between "proc decided to run a turn" and "the turn committed an answer" lies a chain of runtime fences. Each is a point where, if the implementation swapped Redis for Kafka or moved the app to a remote runner, the same guarantee would still have to hold. Read top to bottom, they are the life of a turn:
The app-load fence is the one people forget. Proc has decided a turn should run, but the lane
is not consumed yet — the app instance still has to resolve and its @on_reactive_event
entrypoint has to start the runtime. It is invoked once for that scheduled proc task. Later events are drained
inside the same turn by ContextBrowser; they do not invoke the app entrypoint again. For a
non-singleton app, loading can take observable time. The state table therefore first records
T.consumer = scheduled: a short start reservation that does not claim the old handler
is alive. (@on_reactive_event is the runtime decorator; manifest metadata remains
on_message/OnMessageSpec.)
Superseded turns never commit
The fences exist for one worst case: a turn goes quiet long enough that its consumer heartbeat goes stale, a second turn reclaims the lane, and then the first turn wakes back up. Without a rule, both would try to answer. The rule is: a lane event is folded, and an answer is committed, only for the turn that still owns the lane.
ContextBrowser rechecks ownership at handler open, consumer acknowledgement, each event accept, and
finish_turn. Both the background listener and direct decision/tool-phase watcher use
ContextBrowser.apply_live_external_events(), which reaches the same owner-fenced accept operation;
there is no raw unfenced fold fallback.
Handler-open, fold, and finish mismatches raise ExternalEventLaneTurnSuperseded into standard turn error
cleanup. A mismatch found by the direct phase watcher cancels the active phase; the outer ReAct run closes the
handler, stops the listener, and releases its lease before propagating cancellation. Shutdown and watchdog
cancellation use the same close-before-propagate discipline. These are not one universal
delete_turn(...) route, but every route enforces the same invariant: the stale turn cannot commit an
answer or become the conversation head.
The bus event is not the timeline block
Here the design makes its second sharp cut. Arriving on the bus and appearing on the ReAct timeline are
separate facts. In the current ContextBrowser ingest path, block production runs first. Its
result controls timeline contribution and the later optional workflow/ReAct hook.
A source can bind react.block_production.no_timeline; the occurrence then advances the processed
cursor and is marked consumed without creating a durable ReAct block. This is a visibility choice, not proof
that a generic callback persisted product state. Required business durability belongs in the producing service
or explicit source-owned processing. A policy that shares file material can expose only refs such as
conv:fi: or owner-namespace refs and let ReAct use the supported react.read /
react.pull path on demand.
Kind is transport, type is meaning
One more separation, and it is the one that most recently bit us in production. Every lane event carries two names: an operational lane kind and a semantic event type. The kind is a scheduling label. The type is the meaning.
lane kind message · followup · steer · external_event <- operational / scheduling
event type event.user.prompt · event.user.followup · <- semantic (what it MEANS)
event.user.steer · event.external · event.canvas · event.snapshot
The catch: in-flight events submitted through the plural external_events[] batch all arrive with
lane kind = external_event. The real type (event.user.steer,
event.user.followup) lives only nested inside payload.event.type. A live consumer that
branches on the kind would see one undifferentiated external_event for everything — and a live
"stop" would be silently dropped.
So live consumers must recover the semantic type before deciding anything. The ReAct runtime
does exactly this — on_external_event calls
live_events.recover_semantic_event_type(...) to dig the real type out of the nested payload before it
decides steer-interrupt vs iteration-credit. Key off the transport label and you drop the user's intent; key off
the recovered type and the bus stays honest.
Steer and followup: live control on the bus
Because control rides the same lane as content, the two live control events are worth their own picture. Both target an already-running turn; each does the opposite thing.
event.user.followup, continuation —
may add bounded iteration credit, and the turn keeps working.event.user.steer, continuation —
requests cancellation of the active phase, then enters bounded finalize.A followup is new work. When live reactive iteration credit is enabled, an eligible
current-turn followup can add credit once per event, bounded by configured per-event and total caps. A
steer is control: empty text with explicit is a stop; text is a redirect. ReAct
requests cancellation of the active decision/tool phase and enters a bounded finalize. Isolated
execution performs its cleanup when cancellation reaches it; the protocol does not promise instantaneous
termination of every possible tool process.
The subtlety the bus forces on us: a steer only stops the current work — a followup is a valid way to continue the turn after a steer. So a queued followup must supersede the steer's bounded finalize, regardless of whether it arrived before or after the steer, and let its generation run at full budget — while a bare steer with nothing queued still gets its short wrap-up. Runtime target checks ignore steer/followup control stamped for another active/owner turn. If reactive work remains unconsumed when a turn closes, the post-save handoff may enqueue a new lane wake; the current production path does not use a separate generic "promotion" stage.
Why a bus, and not a queue
Every separation in this design pulls apart two things a naive message queue would fuse. Laid side by side:
| The bus keeps apart | …so that | A plain queue would fuse them into |
|---|---|---|
| Wake vs event body | the processor decides on current lane state, not a frozen request | replaying a stale request body |
| Handler vs consumer | a dead worker's open handler can be safely reclaimed |
ownership that never recovers |
sequence vs queue |
order is authoritative even as wakes race | the queue accidentally defining order |
| Bus event vs timeline block | a widget save can be bus-only; a prompt is model-visible | every event polluting the model context |
| Lane kind vs event type | a live "stop" is recovered, never dropped | control lost behind a transport label |
| Fold vs commit | a superseded turn is discarded before it can answer | two turns racing to commit |
Read down the middle column: each row is a failure the naive design ships and the bus refuses. That is the through-line — the event bus is not a delivery mechanism with features bolted on; it is a set of deliberate separations, each earning its keep against a specific way conversations break when turns overlap.
Where this is going
The current implementation runs the app and the ReAct runtime inside chat-proc. That is an
implementation detail, not a semantic requirement — a remote app runner would inherit the same fences:
accepted-event storage, wake scheduling, lane ownership, consumer heartbeat, ordered lane read, and superseded-turn
rollback before persistence. The fences are the contract; where the code runs is not.
Two gaps remain explicit. Non-reactive idle events are retained today
only in the Redis lane, with a bounded window (CHAT_EXTERNAL_EVENTS_STREAM_MAX_ENTRIES, default 1024;
CHAT_EXTERNAL_EVENTS_STREAM_RETENTION_SECONDS, default 7 days) — operational state, not durable
business history.
Durable idle event-history materialization is pending, and the goal is to stop pretending Redis
retention is permanent conversation history. And the conversation-native scheduler — folding
out-of-turn non-reactive events into a durable timeline without waiting for the next turn — is still design-only.
The reframe holds through all of it: one conversation/agent id_card is a lane; a turn briefly owns
it; and the discipline that keeps overlapping turns correct — ownership, liveness, supersession — is the same
whether the lane is Redis or Kafka, and whether the consumer runs in-proc or across the network.
Documentation on GitHub
The live docs behind this entry:
- External events journey and handling
- External events — the compact protocol
- External event envelope
- Event ingress to a ReAct turn
- Conversation event-lane state
- Shared timeline — steer & followup
- Event-source subsystem
- Conversation event-bus orchestrator
- Ingress event inception
- Proc events orchestration