KDCube
← Engineering

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.

ONE ID_CARD = ONE ORDERED CONVERSATION / AGENT LANE 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 → … WHILE OWNER ContextBrowser reader reads accepted events in lane sequence — only for the owning turn. PER SOURCE block production apply the registered policy to the accepted occurrence produce zero or more blocks. OUTCOME consume or contribute zero: cursor + consumed blocks: timeline contribution then optional registered hooks. The ReAct timeline is one consumer of the bus — not the definition of the bus.
One 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:

REACTIVE: ATOMIC BODY + WAKE — NON-REACTIVE: BODY ONLY external_events[] chat-ingress validate auth/session • resolve id_card • normalize ids & ts reactive batch: one atomic admission — both branches or neither THE BODY Lane L append accepted events with event_id + sequence non-reactive batches stop here A POINTER Queue Q enqueue a small wake points back at the occurrence reactive only; schedules work, no body Why: the wake (ExternalEventLaneWakeup) carries lane coordinates but intentionally not the body — so the processor decides on current lane state, not a frozen snapshot. Prompt / attachment / steer text are recovered from task_payload.
Reactive admission atomically writes the event body to 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:

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 reactive: atomic L + Q; non-reactive: L only 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 App load / entrypoint proc (today) · the load fence invoke @on_reactive_event once — T.consumer=scheduled holds start 6 Handler open ReAct runtime T.handler_turn_id = me — or this turn is superseded 7 Consumer acknowledgement ContextBrowser reader refresh owner lease + consumer_status_at while owner 8 Lane read + block production listener + direct phase watcher apply_live_external_events → owner-fenced accept 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 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.

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 handler / accept / finish: standard turn error cleanup phase watcher: cancel → close listener → release lease B continues from the last committed index — A cannot commit a stale answer or head. 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 the next ownership fence stops A through standard error cleanup or active-phase cancellation. Only the current owner ever commits.

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.

THE BUS EVENT IS NOT THE TIMELINE BLOCK — SOURCE POLICY PRODUCES 0..N BLOCKS accepted event → block-production policy ZERO BLOCKS consume without timeline advance processed-event cursor mark occurrence consumed add no ReAct timeline block no generic post-block hook ONE+ BLOCKS contribute produced blocks produce timeline block(s) snapshot / canvas / ref block bounded metadata + refs only then optional registered hooks only PRODUCED blocks reach the timeline projection · announce · compaction see produced blocks Important — no_timeline is visibility only It does not prove that a generic callback stored product state or ran side effects. Required durability belongs to the producer or explicit source-owned processing. The ReAct ingest path makes no unconditional callback promise. Accepted on the lane does not imply materialized on the ReAct timeline.
Accepted on the lane does not imply materialized on the ReAct timeline. The source policy produces zero or more blocks, and hooks follow block contribution rather than running unconditionally.

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.

A followup continues the turn. event.user.followup, continuation — may add bounded iteration credit, and the turn keeps working.
A steer stops the current work. 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:

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 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.

KDCube Deep · 02.07.2026