OpenAI Codex · Event language

SQ in, EQ out: two faces of the same thing

Commands walk an in-process Submission Queue. Events walk an Event Queue that can be written as JSON. Rust calls it TurnStarted; disk still writes task_started.

Course goalAfter this you can name three things. Commands enter the kernel on the Submission Queue; events come out on the Event Queue. Same lifecycle: the code says TurnStarted, JSON writes task_started. An old client that hits an unknown type fails to compile in-process, fails to decode cross-version JSON, and on resume of an old file skips the line and still opens.
Try it first · The same thing: what in and out each look like
Drop a TurnInput in; watch how the SQ envelope and EQ box line up
type on the lid
The first two both decode as TurnStarted. The third: MCP smashes, resume skips the line. Enter applies it.
Downlink · Submission Queuebounded 0/512
No command dropped in yet
Submission envelopeWaiting for a submit. Only id and op, no JSON.
Uplink · Event Queueunbounded · 0 items
No event yetWalk the command channel on the left first.
MCP as-is doorWaiting for an event
resume skip-line doorWaiting to persist
Contrast exitsDSH / Grok not on stage yet
Logic trail · each animation step maps to a source span
  1. Mint a UUID7 as the submission idsession/mod.rs L918
  2. Wrap the Op as a Submissionsession/mod.rs L817
  3. Send it into the SQ of capacity 512session/mod.rs L833
  4. submission_loop dispatches by varianthandlers.rs L526
  5. send_event uses sub_id as Event.idsession/mod.rs L1952
  6. Emit a legacy copy when neededsession/mod.rs L1965
  7. An allowlist decides whether to write the rolloutsession/mod.rs L2169
  8. Send it into the unbounded EQsession/mod.rs L2185
  9. MCP serializes the whole Event as codex/eventoutgoing_message.rs L117
  10. On resume, a bad line increments parse_errorsrecorder.rs L1046
Hit Play. Watch the same sentence enter on SQ and leave on EQ, and what each side looks like.
The inbound shapeLeft is an in-process command. TurnInput carries a oneshot callback, so the whole Submission does no serde.
The outbound shapeRight is an Event that can be written as JSON. The id matches the left-hand submit; the type on the lid is the outward word.
Flip to future_eventMCP cannot decode it. resume drops the line into parse_errors; the session still opens. DSH refuses the whole log; Grok collects it as Unknown.
Teaching sketch:The submit id is a short teaching number; the real implementation is UUID7. Line numbers on the logic trail map to openai/codex commit 4f39251a01.
Idea 1 · Commands and events are two languages
What problem it solves

You told the sidebar to wait for type equal to turn_started. On integration day the fields lined up, but type was written task_started. You switch to the new name; the old name in old fixtures still decodes.

Then you added an event of your own. Local and kernel compiled together; it passed. The old MCP client next door could not decode it. A week later, a rollout written by the new build (the session-on-disk file) is resumed in the old build. That line is skipped, parse_errors increments, the session still opens — one lifecycle slice is missing.

A command carries a oneshot callback, an approval decision, even a realtime audio frame. An event has to enter the rollout, be written as JSON by MCP, and be dispatched by old clients on type. Direction, lifetime, and whether it can cross the wire — stacked on one “message” they drag each other down.

What the idea is

The module header uses four lines to lock the speaking style: in one session, client and agent talk asynchronously over SQ / EQ.

codex-rs/protocol/src/protocol.rslines 1–4
//! Defines the protocol for a Codex session between a client and an agent.
//!
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
//! between user and agent.
Source snapshot: from the local repo openai/codex, checked file codex-rs/protocol/src/protocol.rs, commit 4f39251a01, checked on 2026-08-22. The code block keeps the original. These four lines are the lesson’s pattern statement.

The downlink item is a Submission. It has an id for correlation, an Op to run (a kernel verb; 28 today), derives only Debug, and has no serde. The uplink item is an Event. It has serde. The id matches the original submit; msg is the event body.

Source:codex-rs/protocol/src/protocol.rs lines 185–200; codex-rs/protocol/src/protocol.rs lines 1276–1283

Session start builds both channels at once. Downlink is bounded, capacity 512. Uplink is unbounded. If the client fires 512 items the loop has not taken, the next send waits. Events can pile up, use memory, and do not back-pressure this turn.

Source:codex-rs/core/src/session/mod.rs lines 460–461; codex-rs/core/src/session/mod.rs lines 533–534

Client submit Op SQ 512 submission_loop Dispatch by Op variant send_event Event Queue unbounded Client next_event The same UUID7: Submission.id on the left, Event.id on the right
Teaching diagram:Commands enter on the left, events leave on the right, matched by the same id.

A TurnInput routing result walks oneshot, not the Event Queue. EventMsg describes what happened this turn. oneshot only answers “was this submit caught.”

Source:codex-rs/core/src/session/handlers.rs lines 515–526

Why it lasts

Commands are sent by people, at low frequency; a jam can back-pressure. Events are sprayed by the model and tools; a jam would stall this turn. Rewrite in another language: as long as commands carry callbacks and events must persist, these two queues still have to split.

Idea 2 · The wire name keeps the disk; the code name can change
What problem it solves

The Rust variant is already renamed TurnStarted. If the JSON string followed, old rollouts and old clients would break at the deserialize boundary. Guessing the wire name from the identifier guesses wrong.

What the idea is

serde writes task_started and also reads turn_started. Display and metrics walk turn_started. One variant, two strings: disk keeps the old name, code uses the new one.

Source:codex-rs/protocol/src/protocol.rs lines 1337–1340

The item lifecycle also sprays one more old name. New frontends watch ItemStarted; old frontends watch ExecCommandBegin or AgentMessage. Duplicate meaning will sit on the queue. That is a migration path, left for consumers not yet moved to TurnItem.

Source:codex-rs/core/src/session/mod.rs lines 1965–1973; codex-rs/protocol/src/legacy_events.rs lines 65–69

TurnStarted Rust variant name serde writes Display task_started What disk and MCP see turn_started Metrics and alias read Old rollouts still decode The new name is only a read alias
Teaching contrast:Change the identifier without changing the disk. The cost: one variant must remember two strings at once.
When you rename in code, use rename first to keep the string already on disk.
Why it lasts

Identifiers can change; strings already on disk cannot afford to. rename plus alias is the usual back door for disk. Which set metrics use needs its own test — do not assume it matches serde.

Idea 3 · Write down the default direction for an unknown type first
What problem it solves

EventMsg is the internal event vocabulary: 81 variants, no #[serde(other)], not marked non_exhaustive. Add a new type and the old reader’s fate cannot be “it depends.”

What the idea is

Three paths; the answers are all written in the code.

Same process, same version

TUI, exec, MCP, and the kernel link the same type. An exhaustive match will not compile. An old client that has not upgraded will not be linked with this new kernel at all.

Cross-version JSON

MCP serializes the whole Event as codex/event. An old client decodes with the old vocabulary; an unknown type makes serde fail. The kernel already sent it; the failure happens on the client.

resume an old file

A bad line increments parse_errors, then continue. An unknown type will not keep the whole session from opening. It will miss one line. The function still returns the items it already decoded.

Source:codex-rs/mcp-server/src/outgoing_message.rs lines 108–133; codex-rs/rollout/src/recorder.rs lines 1009–1071

Op flips. It is marked non_exhaustive; the tail of submission_loop is _ => false; an unknown command is dropped and the loop does not crash. Events are the outward vocabulary — a missing variant must be seen at compile time. Commands face inward extension; dropping is safer than crashing.

Source:codex-rs/core/src/session/handlers.rs line 684

Unknown type In-process: exhaustive match will not compile Cross-version JSON: serde fails resume: skip the line, parse_errors +1 Session still opens, one line short
Teaching path diagram:The same unknown event has one landing each at compile time, the JSON boundary, and disk restore.
Why it lasts

Vocabularies change. Decide the default direction for an unknown type first: refuse to open, skip the bad line, or collect as Unknown. All three can be copied — do not let the three paths each invent a set and never write it down. Source-of-truth events and notification streams can have different defaults, but write them on the envelope.

Side-by-side · What to do with a type you do not know

DSH: unknown and not marked ignorable is a refuse

DSH treats the event log as source of truth. The envelope has ignorable?: true. Without that mark, a reader that hits an unknown type must refuse to rebuild — it cannot quietly drop. Forget the mark and you over-refuse, which is safer than silently restoring a hollowed-out session.

The cost is clear: an old harness cannot open a new log. What you buy is “if it opens, it is complete.” Codex’s EventMsg is already 81, and still has to emit ephemeral events for exec output and approvals. If all of that became source of truth, the JSONL would grow by the token.

Checked against source · 2026-08-22 · DSH · Log-rebuild invariants · packages/core/session/src/types.ts lines 404–422

Grok: unknown collects as Unknown, must ignore silently

Grok’s session event protocol has only 6 variants. Unknown carries #[serde(other)]. The module header says: an old consumer that hits a new event_type decodes as Unknown and must not fail. Consumers must ignore silently. The original type name is not kept.

Fits a notification stream. Drop a notice and the session can still live on other state. Codex’s TurnStarted is a rollout cut boundary; a source-of-truth event cannot be dropped silently. The resume path skips the bad line — closer to “open” than Grok, closer to “open if you can” than DSH.

Checked against source · 2026-08-22 · crates/common/xai-tool-protocol/src/session_event.rs lines 11–65
Classroom Exercise
01

Three JSON lines, four exits

Prepare three lines whose type is task_started, turn_started, future_event. Walk MCP as-is decode, Codex resume, DSH, and Grok. Which line fails MCP, which line makes DSH refuse the whole log, and which two lines are actually the same variant in Codex.

Advanced: if TurnStarted serde kept only rename = “turn_started”, on which boundary would an old rollout break.

Takeaway:Split the command channel from the event channel: commands may carry callbacks, events must serialize as JSON. Write the wire name and the code name apart; when you rename an identifier, use rename to keep the disk. For an unknown type, pick a default direction first: refuse, skip the line, or collect as Unknown.