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.
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.
- Mint a UUID7 as the submission idsession/mod.rs L918
- Wrap the Op as a Submissionsession/mod.rs L817
- Send it into the SQ of capacity 512session/mod.rs L833
- submission_loop dispatches by varianthandlers.rs L526
- send_event uses sub_id as Event.idsession/mod.rs L1952
- Emit a legacy copy when neededsession/mod.rs L1965
- An allowlist decides whether to write the rolloutsession/mod.rs L2169
- Send it into the unbounded EQsession/mod.rs L2185
- MCP serializes the whole Event as codex/eventoutgoing_message.rs L117
- On resume, a bad line increments parse_errorsrecorder.rs L1046
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.
The module header uses four lines to lock the speaking style: in one session, client and agent talk asynchronously over SQ / EQ.
//! 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.
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
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
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.
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.
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
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.
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.”
Three paths; the answers are all written in the code.
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.
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.
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
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.
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.
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.
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.
type, pick a default direction first: refuse, skip the line, or collect as Unknown.