A conversation looks like a request and a reply. Underneath, it is not. It is an ordered event bus — one lane per conversation — and a turn is just a consumer that briefly owns that lane. Everything a user or a surface does becomes an ordered event on it: a typed prompt, a file dropped on a widget, a webhook from a third-party app, a mid-flight "stop." They all land in the same stream, in the order the lane assigned, and a running agent turn folds them in.

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.

ONE CONVERSATION = ONE ORDERED LANE — MANY PRODUCERS IN, MANY CONSUMERS OUT client typed prompt widget canvas / file save webhook third-party app API caller external_events[] Lane L ordered per id_card • sequence assigned by Redis e1 e2 e3 e4 e5 → … CONSUMER ReAct turn folds lane events into its timeline, in order — one consumer, not the bus. SIDE EFFECTS app callback host bytes • call an API update a DB • check perms … or just observe. POLICY block-production gate visible timeline block? refs only? • snapshot? … or bus-only, no block. The ReAct timeline is one consumer of the bus — not the definition of the bus.
One conversation = one ordered lane. Every producer writes into it in sequence; the ReAct timeline is just one of the consumers that reads back out.

One lane per conversation

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 does two independent writes:

INGRESS SPLITS “THERE IS WORK” FROM “THE WORK” external_events[] chat-ingress validate auth/session • resolve id_card • normalize ids & ts the body a pointer — only if reactive THE BODY Lane L append accepted events with event_id + sequence the full request lives here A POINTER Queue Q enqueue a small wake points back at the occurrence schedules work; carries no body Why: the wake (ExternalEventLaneWakeup) carries lane coordinates but intentionally not the request — so the processor decides on current lane state, not a frozen snapshot. Prompt / attachment / steer text are recovered from task_payload.
Ingress splits the body from the wake: the event body goes to the lane L; a small pointer goes to the queue Q — and only when reactive work exists.

The body goes to the lane L. A small wake goes to the processor queue Q — and only if the batch actually contains reactive work. That wake is an ExternalEventLaneWakeup: service/routing/user context plus the lane coordinates to find the occurrence — but it intentionally does not contain the request. The prompt text, the attachment, the steer text, the domain payload — all of that is recovered later from the lane event's stored task_payload.

There is one event protocol, ExternalEventPayload; the queue merely carries a pointer back to it. Why go to this trouble? Because a wake can be stale by the time it is dequeued — a later turn may already have consumed past it — and because promotion of an unconsumed event should re-point at the current lane state, not replay a frozen request body. Keeping the body in L and only a pointer in Q is what lets the processor make an up-to-date decision at dequeue time instead of acting on a snapshot of intent.

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_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. Liveness lives with the consumer, ownership lives with the handler, and the whole recovery protocol turns on not mistaking one for the other.

  • 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:

FOUR WAYS TO ANSWER ONE WAKE — IGNORE, DEFER, DEFER, OR SCHEDULE processor dequeues a wake wake already covered by the processed cursor? T.last_processed_reactive_event_timestamp YES · obsolete NO a fresh consumer present? T.consumer_status active scheduled none STALE WAKE IGNORE the wake is obsolete — a cursor already proves the work was handled. LIVE OWNER DEFER a live consumer heartbeat is fresh; it will fold the event. STARTER PENDING DEFER a starter is already reserved during app load; don't duplicate. NEW TURN SCHEDULE nothing fresh covers the lane; mark scheduled and load the app. Ignore = the wake is obsolete. Defer = the wake is valid, but someone already has it covered.
Four outcomes, one wake: ignore a stale pointer, defer to a live or pending owner, or schedule a fresh turn.
  • 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 = scheduled and 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 FENCE CHAIN — FROM CLIENT SUBMIT TO TURN COMMIT, EACH RUNG AN IDEMPOTENCE CHECKPOINT 1 Client submit browser / widget / webhook / API the caller's target turn is intent, not authority 2 Ingress admission chat-ingress append to L; enqueue a wake to Q only if reactive 3 Wake scheduling proc worker “there is work” → “may I start?” — the body stays in L 4 Lane ownership decision proc worker ignore · defer · or schedule (see the wake decision tree) 5 Bundle load / entrypoint proc (today) · the load fence resolve app, start @on_reactive_eventT.consumer=scheduled holds it 6 Handler open ReAct runtime T.handler_turn_id = me — or this turn is superseded 7 Consumer acknowledgement ContextBrowser reader refresh consumer_status_at only while still owner 8 Lane read + block production ContextBrowser + policy fold events in order — only for the current owner 9 Turn commit finish_turn only a non-superseded turn may persist answer / index The fences are the contract; where the code runs — Redis or Kafka, in-proc or remote — is not.
The fence ladder from client submit to turn commit. Each rung is an idempotence checkpoint — the contract a Kafka or remote-runner implementation would still have to honor.

The bundle-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. That is precisely why the state table first records T.consumer = scheduled: it is a short reservation that stops a competing turn from starting during the load window — while explicitly not claiming the old handler is alive. (@on_reactive_event is the real decorator; the manifest metadata field is still named 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.

A STALLS, B RECLAIMS, A RESUMES — ONLY THE CURRENT OWNER EVER COMMITS Turn A opened the lane first Turn B the reclaiming turn handler = A handler = B ① opens the lane consumer heartbeat fresh ② stalls — worker pause the consumer heartbeat goes stale ③ reclaims the lane no fresh heartbeat from A ④ resumes, rechecks ownership handler-open · ack · accept · finish ⑤ owner = B → superseded raise ExternalEventLaneTurnSuperseded rollback: delete_turn(index_only) — no answer, no head B continues from the last committed index — the stale turn was discarded as a wrong turn. Late execution is idempotent at the turn boundary — a delayed turn can wake up, but it cannot become the head.
A stalls, B reclaims, A resumes — and at the next ownership recheck A raises ExternalEventLaneTurnSuperseded and rolls back. Only the current owner ever commits.

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. On mismatch it raises ExternalEventLaneTurnSuperseded, which is deliberately routed through the ordinary turn-exception path — the stale turn is discarded like any wrong turn, its partial in-memory work abandoned, while the newer owner continues from the last committed index. Late execution becomes idempotent at the turn boundary: a delayed turn can wake up, but it cannot make itself 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 two separate decisions. The bus preserves order and hands the app a callback; a block-production policy independently decides whether the event also becomes model-visible timeline material.

THE BUS EVENT IS NOT THE TIMELINE BLOCK — TWO SEPARATE DECISIONS accepted lane event ALWAYS RUNS app callback host bytes · call an API update a DB · check perms validate access to the object … or just observe the event. PER SOURCE block-production gate produce timeline block(s) snapshot / canvas / ref block bounded metadata + refs only NOTHING — no_timeline only PRODUCED blocks reach the timeline projection · announce · compaction see only these Example — a widget save goes bus-only the callback stores or forwards it, the source binds no_timeline, and the occurrence advances the lane cursor without a ReAct block. real, ordered, acted upon — invisible by design. “Sent through the bus” and “stored on the timeline” are separate decisions.
The block-production gate: every accepted event runs the app callback, but only produced blocks reach the timeline. A widget save can be real, ordered, acted upon — and invisible to the model by design.

This is what lets a widget push a telemetry-style save boundary onto the lane: the app callback stores or forwards it, the source binds react.block_production.no_timeline, and the occurrence advances the lane cursor without ever creating a durable ReAct block. The event was real, ordered, and acted upon — and invisible to the model by design. Conversely, when a policy does share file material, it can share only refs (fi: paths, hosted artifacts) and let ReAct pull the bytes on demand with react.read / react.pull — the body never has to be inlined into the event.

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.

A followup continues the turn. event.user.followup, continuation — mints iteration credit, and the turn keeps working, fresh.
A steer stops the current work. event.user.steer, continuation — cancels the in-flight generation or tool, and the turn enters a bounded finalize.

A followup is new work: it mints reactive-iteration credit and the turn keeps going. A steer is control: an empty steer with explicit is a pure stop; a steer carrying text is a redirect. On a stop, the runtime cancels the in-flight phase (the docker tool container is killed if one was running), preserves the partial output, and enters a bounded finalize — a couple of capped rounds that produce a short "stopped at your request" wrap-up, then end.

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. And a steer is a live control event only: it is never promoted after the active turn expires, because a "stop" for a turn that already ended is meaningless. Followups and generic events can be promoted; control cannot.

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:

Each row is a separation the bus keeps — and a failure a plain queue would ship.
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 are named honestly in the status table. Non-reactive idle events are retained today only in the Redis lane, with a bounded window (CHAT_EXTERNAL_EVENTS_STREAM_MAX_ENTRIES, default 1024; …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: a conversation is a lane; a turn is a consumer that 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.

KDCube Deep · 02.07.2026