The Conversation Is a Lane
A conversation is an ordered event lane. One bus, two consumption models — live ReAct folding and run-to-completion turns — fenced at every seam.
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. A typed prompt, a file dropped on a widget, a
webhook from a third-party app, or a mid-flight "stop" can all
enter through the same external-event protocol. Once accepted into one lane,
their order is the sequence assigned by that lane.
Once a conversation is a lane instead of a call, the hard engineering is not delivery — Redis can store and order records. It is coordination: who may start, who owns the lane, and how a live consumer prevents a stale owner from committing.
This article follows the common path from admission to the app's
@on_reactive_event door, then separates the two processing
contracts KDCube supports today. ReAct is a live consumer:
it opens a handler and can fold events while the turn runs. A
run-to-completion agent folds everything pending when its
turn starts, then watches the lane without folding more content into the loop
it owns. Content that arrives later stays pending; steer can still reach the
running framework through a runtime-specific control. The same bus serves
both without pretending their runtime behavior is the same. The ReAct
timeline and a ported LangGraph turn are two
consumer outcomes of the bus, not definitions of the bus.
01 One lane per id_card
Every accepted event belongs to exactly one lane, named by a composite
identity — the id_card:
id_card = tenant + project + user_id + conversation_id + agent_id
The submitting transport supplies the target agent when one is configured.
For example, Telegram reads the app's
surfaces.as_consumer.default_agent and puts that value on the
submission and its authored events. Only a submission with no configured or
explicit target falls back to default.react.agent. That last
coordinate matters: two agents in the same conversation are two lanes. The
lane is the unit of event ordering and lane-state coordination.
Processor execution has one deliberately broader fence: its Redis
conversation lock is keyed by tenant, project, user, and conversation,
without agent_id. Events for two agent lanes remain
independently ordered, but processor turns for those lanes do not execute
concurrently inside the same user conversation. The full id_card
still scopes each lane's handler/consumer state.
Identity on the lane is deliberately layered, so ordering, occurrence, and meaning never get confused:
id_card which ordered lane + consumer 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.
02 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. 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 the
lane 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.
The prompt text, the attachment, the steer text, and the domain payload are
recovered later from the accepted lane event's stored
task_payload.
There is one event protocol, ExternalEventPayload; for
lane-backed reactive work the processor 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 the
lane and only a pointer in the queue lets the processor decide from
current lane state instead of replaying a frozen request body.
Re-wakes preserve the same rule: ReAct can publish a
post_save_handoff wake after its turn persists, while the
shared run-to-completion door can publish a
run_to_completion_handoff wake for later reactive work. That door
publishes one such wake however many events are pending, and none when the
last thing said was a stop: only intent after the last steer can start a turn.
These handoff wakes are duplicate liveness signals; the atomic ingress wake
remains primary. Finalization releases the lane consumer before it publishes
a handoff wake. Neither wake copies the event body into the queue.
03 One bus, two turn-consumption models
The shared delivery path ends at one app invocation. What happens after that is a consumer contract:
| Contract | Input for this turn | While the agent runs | Turn-end responsibility |
|---|---|---|---|
| Reactive ReAct | Initial accepted lane events after the timeline cursor | Opens T.handler, marks the consumer active, and drains/folds new events through ContextBrowser at safe boundaries | Closes only after the model render has covered accepted events; stops the live reader; persists; releases the consumer; then may publish a liveness wake |
| Run-to-completion | Everything still pending at turn start, in sequence order | Owns execute_core; the shared watcher refreshes its turn-owned reservation and routes steer without folding content | Accounts for the exact start fold, releases scheduled, terminalizes a bare stop, and emits at most one wake for eligible post-steer intent |
The ported LangGraph app is the concrete run-to-completion
implementation. The processor rehydrates the triggering accepted event from
the wake. Because one user submission can store its prompt and attachments
as separate lane records, the app's bundle-local
fold_turn_external_events(...) adapter performs a
read-only fold of the whole pending lane:
wake event → read the conversation lane → select every occurrence still pending (not consumed/promoted/failed) → sort by lane sequence; retain batch, sequence, and arrival stamps → pass accepted event bodies to the selected LangGraph agent
That fold happens once, before graph input is built. It does not open a
handler or mark lane events consumed. Once the graph starts, the shared
watcher keeps its scheduled reservation fresh and observes later arrivals
without putting content into graph state. A steer cancels the LangGraph
stream; the checkpointer retains the last completed node. If the lane lookup
fails, the adapter fails open to the triggering events already dispatched in
state.
The shared door's lane finalizer is state-conditional, idempotent, and
best-effort. It runs after success or error and is skipped when the
processor task is cancelled; cancellation leaves the claimed task on the
normal inflight recovery path. It accounts for the exact start fold, releases
the consumer, terminalizes a bare stop, and emits at most one duplicate wake
for eligible work after the last steer. Earlier events remain pending without
starting a turn. ReAct has already accounted for its live lane state, so the
same finalizer becomes a no-op without an
if ReAct branch. This is one bus with two consumption
models, not a ReAct bus plus a separate LangGraph queue.
04 Scheduled reservation vs live ReAct ownership
The shared state table T contains fields used by both
delivery models and fields used only when a runtime consumes the lane live.
The word consumer in T.consumer_status is coordination
state; it does not always mean a running reader:
scheduled proc reserved one app start; a foreign turn may heartbeat it active a live ReAct ContextBrowser reader is acknowledging liveness none no turn currently holds the lane consumer reservation handler the ReAct turn allowed to accept events into its live timeline
A run-to-completion turn remains scheduled while its graph runs
and never opens T.handler. Proc records its
consumer_turn_id; the read-only watcher refreshes
consumer_status_at only while that owner matches, and the shared
app door releases the reservation afterward. ReAct advances to active,
opens a handler claim — "turn A owns live folding for this lane" —
and maintains the additional ownership fences:
T.handler_turn_id T.handler_status open | closed T.handler_status_at T.consumer_turn_id 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
On that live ReAct path, 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 is paired with the processed
timestamp so equal-timestamp events can be distinguished. Liveness lives
with the consumer, ownership lives with the handler, and the whole recovery
protocol turns on not mistaking one for the other. On the foreign path, a
fresh scheduled timestamp proves only that the named turn still holds its
reservation; it never grants live-folding authority.
The live ReAct 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 actual reader;
T.handler_turn_id and the fresh active consumer heartbeat
remain the turn-ownership and reclaim protocol. They are related signals,
not interchangeable fields.
The lease token fences one listener incarnation, not
merely a turn label. A refresh is valid only for the token Redis currently
owns. Another token means owner replacement even when both records name the
same turn_id; an old listener cannot authorize itself by
matching the turn id.
One ReAct turn also has two legitimate local paths that may request the
listener: hook registration schedules a start, while the active phase
watcher ensures that the listener is running. ContextBrowser
serializes those local start/stop requests so they converge on one listener
and one lease. Terminal cleanup disables an already-scheduled late start.
This prevents one turn from competing with itself without weakening the
distributed Redis token fence.
local start requests → serialized by one ContextBrowser → one lease token same turn_id + current token → refresh allowed same turn_id + different token → owner replaced; old listener stops
fresh active + handler open → do NOT steal the handler; a live owner is folding fresh active + handler closed → recover; no closed handler can consume the event stale/missing → reclaim the lane for a new turn fresh scheduled + matching owner → don’t start a duplicate wake; no live fold is implied
05 A conversation lock, then four wake decisions
After claiming a queue item, proc first acquires the broader
per-conversation execution lock. If another turn for that user conversation
holds it, proc requeues the item; it does not run a second
execute_core. Once it owns that lock, proc resolves the wake
back to the retained lane occurrence, consults T, and picks one
of four conceptual outcomes.
"Ignore" means the wake is obsolete; "defer" means the wake is still valid but someone already has it covered.
The state decision itself uses a short Redis lock. Its value records the holder's operation, process, task, and acquisition time, so a timeout identifies the contended phase without exposing the ownership token. Acquisition, state write, and release complete to a known outcome under cancellation; uncertain ownership is removed by exact token. If proc still times out before app execution, it requeues the claimed wake instead of acknowledging valid lane work as malformed.
06 The shared door, then the ReAct fence chain
Between "proc decided to run a turn" and "the turn committed an answer" lies a chain of runtime fences. The common rows run through the app door; after that, the two contracts diverge.
| Fence | Owns | Guards |
|---|---|---|
| Client submit | browser / widget / webhook / API | Caller's target turn is intent, not authority |
| Ingress admission | chat-ingress | Atomic lane + queue for reactive; lane only otherwise |
| Wake scheduling | proc worker | "There is work" → "may I start?" — the body stays in the lane |
| Conversation execution | proc worker | One task per user conversation; requeue on lock contention |
| Lane ownership decision | proc worker | Ignore / defer / schedule |
| App load / entrypoint | proc (today) | Invoke @on_reactive_event once; T.consumer = scheduled |
| Reactive ReAct | ||
| Handler open | ReAct runtime | T.handler_turn_id = me, or I'm superseded |
| Listener lifecycle | ContextBrowser | Hook + phase requests converge on one token-fenced lease |
| Consumer acknowledgement | ContextBrowser reader | Refresh owner lease + active heartbeat |
| Lane read + block production | listener / phase watcher | apply_live_external_events → owner-fenced accept |
| Handler close | ContextBrowser | Close gate stops the live reader immediately |
| Turn commit | finish_turn | Only a non-superseded handler may persist |
| Turn finalization | finish_turn | Release consumer; then optional liveness wake |
| Run to completion | ||
| Pending-lane mapping | app adapter | Whole pending lane, sequence ordered and stamped |
| Live watch | shared foreign-runtime seam | Refresh owned reservation; route steer; never fold content |
| Graph / loop execution | execute_core | The framework owns its loop |
| Door finalization | BaseEntrypoint.run | Account exact fold; release; at most one post-steer wake |
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. For a non-singleton app this can take
observable time. The entrypoint is invoked once for that scheduled proc
task. After it starts, ReAct may read later events inside the same turn; a
run-to-completion app does not fold them, but its watcher can route control.
The state table first records T.consumer = scheduled to stop a
duplicate starter during load; the matching watcher then keeps that owned
reservation fresh for the life of the foreign turn while explicitly
not claiming live-folding authority.
(@on_reactive_event is the runtime decorator; the
manifest metadata field is still named
on_message/OnMessageSpec.)
07 Live ReAct supersession: a stale handler never commits
The live ReAct 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.
A lane event is folded, and an answer is committed, only for the turn that still owns the lane.
ContextBrowser rechecks ownership at every moment where
stale work would matter — handler open, consumer ack, each event fold via
accept_events_for_open_handler(...), and once more at
finish_turn before the answer is emitted. Both the background
listener and the direct ReAct decision/tool-phase watcher send events
through ContextBrowser.apply_live_external_events(), which
reaches that same owner-fenced accept operation. There is no raw unfenced
fold fallback.
The token fence catches a narrower replacement too: if the stored owner
token changes while the diagnostic turn_id stays the same, the
old listener still stops. Turn ownership and listener-incarnation ownership
are checked together; neither identifier substitutes for the other.
On mismatch, handler-open, fold, and finish boundaries raise
ExternalEventLaneTurnSuperseded into standard turn error
cleanup. If the direct phase watcher detects the mismatch, it cancels the
active phase; the outer ReAct run then closes the event handler, stops the
listener, and releases its owner lease before propagating cancellation.
Processor shutdown and watchdog cancellation use the same
close-before-propagate discipline. The cleanup route is therefore not one
universal delete_turn(...) call, but every route enforces the
same invariant: the stale turn cannot commit an answer or become the
conversation head.
This is specifically the ReAct live-handler guarantee. A run-to-completion graph never opens that handler and therefore does not claim these ReAct supersession fences. Its turns are serialized by the processor conversation lock; its turn-owned scheduled heartbeat prevents a second starter; runtime-specific control can stop the work; and task/inflight recovery remains the crash path.
08 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 both 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 a promise that a generic callback persisted product state. Any
business operation that must always happen belongs in the producing service
or in explicit source-owned processing, not in an assumed unconditional
ReAct callback. Conversely, when a policy does share file material,
it can share only refs such as conv:fi: paths or
owner-namespace refs and let ReAct use the supported react.read
/ react.pull path on demand. The file body does not have to be
inlined into the event.
A run-to-completion agent does not automatically pass through this ReAct block policy. It maps the pending lane at turn start into its own framework input. The ported LangGraph app, for example, frames prompt text and attachment refs for the graph; afterward the platform records a framework-neutral minimal turn log. Neither step creates a ReAct timeline.
09 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.
The run-to-completion start fold contains the accepted event bodies,
including their semantic type; its adapter likewise reasons
from that type, not from the lane's operational kind.
10 Followup and steer depend on the consumption model
Because control rides the same lane as content, the two live control events are worth their own picture. The behavior below belongs to an agent that declares and implements live followup/steer consumption — today, the ReAct path:
A FOLLOWUP continues the turn A STEER stops the current work event.user.followup, continuation event.user.steer, continuation → may add bounded iteration credit → requests cancellation of the active phase → the turn keeps working, fresh → the turn enters a 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 the configured per-event value and total cap. A
steer is control: an empty steer with explicit
is a pure stop; a steer carrying text is a redirect. The runtime
requests cancellation of the active decision/tool phase and enters a
bounded finalize. Isolated tool execution performs its own
cleanup when cancellation reaches it; the protocol does not promise that
every possible tool process is instantaneously killed.
Ingress treats event.user.steer as active-turn control before
it reaches either consumer model. It accepts the control only while the
conversation is in_progress, stamps the server-observed active
turn id, and acknowledges an idle or stale-target steer as a no-op. The event
remains reactive so it can wake the live lane, but it is not promotable: proc
cannot start a turn from it. A bare steer is terminalized once its target turn
ends. A steer carrying text stays pending for a future start fold but cannot
start that turn by itself. A later owner rejects control fenced to an older
turn. Telegram /stop uses this exact event and fence; it has no
transport-specific stop queue.
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 duplicate liveness wake after the consumer is released; the atomic ingress wake remains primary. The current production path does not use a separate generic "promotion" stage on the ReAct path. Run-to-completion uses the shared door finalizer described above.
The ported LangGraph agents declare
accepts_followup: false and accepts_steer: true. They
do not absorb content into a graph already running; the watcher instead
cancels the graph stream on steer. The checkpointer retains the last completed
node, and the adapter repairs a dangling tool-call boundary before later model
input. Pending content is folded together when an eligible event starts one
later serialized turn. There is still no ReAct-style iteration credit.
11 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 |
| Agent lane vs conversation lock | each agent has independent event order while execution in one user conversation stays serialized | event identity and execution exclusion becoming one accidental key |
| Scheduled reservation vs active ReAct reader | app load is protected without pretending a live reader exists | a start timestamp being mistaken for runtime liveness |
| ReAct handler vs active heartbeat | a dead live consumer's open handler can be safely reclaimed | ownership that never recovers |
sequence (order) vs queue (schedule) | order is authoritative even as wakes race | the queue accidentally defining order |
| Pending-lane start fold vs runtime consumption | ReAct may fold live while a ported graph freezes one ordered view at start, then watches only for control | every framework being forced into fake live reactivity |
| Bus event vs ReAct timeline block | a widget save can be bus-only; a prompt can become model-visible | every event polluting model context |
| Lane kind vs event type | a live "stop" is recovered, never dropped | control lost behind a transport label |
| ReAct fold vs ReAct commit | a superseded live handler is discarded before it can answer | two live handlers 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.
12 Where this is going
The current implementation runs both ReAct and the ported LangGraph app
inside chat-proc. That is an implementation detail, not a
semantic requirement. A remote app runner would still need the common
contract: accepted-event storage, wake scheduling, conversation
serialization, start reservation, and turn-end release/handoff. A runtime
that opts into live consumption additionally needs the handler, heartbeat,
owner lease, ordered live read, and superseded-turn rollback fences. The
contract follows the consumption model; it is not inferred from where the
code runs.
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 one ordered lane; proc starts turns through a
broader per-conversation execution fence; and each app declares whether its
turn is a live lane consumer or a run-to-completion consumer that folds the
pending lane once and watches without folding afterward. Redis is the current
implementation. The boundaries are the design.