Before You Stuff Context, Give It a Type
Approved prefixes, the workspace, AGENTS.md, an interrupted turn — all tell the model a fact it cannot see for itself. Codex first mints a type for each injection; the type knows its own role, marker, and body.
- Read role: user or developerfragment.rs L15
- Ask whether it needs its own messagefragment.rs L18
- The instance wants markers; use them to renderfragment.rs L22
- Empty markers emit only the body and never matchfragment.rs L41
- Window identity is pushed into the separate bin before the loopsession/mod.rs L3661
- Other fragments sort by role and markersession/mod.rs L3677
- On the user side, the registry .any() claims it backcontextual_user_message.rs L18
- On the developer side, the prefix table claims it backevent_mapping.rs L40
build_initial_context_with_world_state. Line numbers on the logic trail match openai/codex commit 4f39251a01.You’ve added this kind of feature to an agent: the user just approved npm *, and next turn the model still asks whether to run npm test. Or compact just finished and the model suddenly forgets where the workspace is, or whether the sandbox is read-only. Or you fork a session: the old AGENTS.md is still there, a new directory hint stacks on top, and the model sees two sets of rules fighting.
All three share one kind of operation: at runtime, a stretch of text is stuffed into model context. Approved prefixes, workspace, permission profile, an interrupted turn, the current UTC time — all tell the model a fact it cannot see. A single format! can write it.
The trouble is afterwards. Once that text is in history, whether compact keeps it, restore claims it, the UI hides it, or world state treats it as something the model already knows — all depend on one thing: you can still claim it back from plain text. Write another startsWith in each place and in a few weeks they drift. Unknown tags also leak into user-message parsing.
Codex folds each injection into a type that implements ContextualUserFragment. The trait is not in codex-core; it lives in a separate crate, codex-context-fragments. Every impl must answer at once: does this enter the Responses API as user or developer, what are the begin/end markers, how is the body written, and does it need its own message.
render() concatenates begin marker, body, and end marker with no separator. Whitespace and newlines belong to the body. Empty markers emit only the body. into() folds the render into a ResponseItem::Message whose content is a single InputText. The injection ends as a protocol object.
fn render(&self) -> String {
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{start_marker}{body}{end_marker}")
}
openai/codex repo; checked against codex-rs/context-fragments/src/fragment.rs, commit 4f39251a01, checked on 2026-08-22. The code block keeps the source as-is. This is the whole rule for a type folding itself into injectable text.markers() takes self — render walks the instance. type_markers() does not — claim walks the type. With only history text and no original object, you can still ask whether this stretch looks like a given fragment. dyn ContextualUserFragment cannot call matches_text; reverse claim has to name a concrete type.
Adding an injection means a new file, a trait impl, and a hook into core/src/context/mod.rs. Thirty-nine modules in that directory. The friction itself is governance: a throwaway format! cannot get through, because the assembler takes Box<dyn ContextualUserFragment>.Source: codex-rs/core/src/context/mod.rs lines 3–41; codex-rs/core/src/session/mod.rs lines 3677–3707
Render and claim share one definition. Rewrite it in another language and the minimum is still one interface, one registry, the same marker pair. Type the assembler parameter as a fragment and the business side loses the call site for casually glued XML. The claim function only matches from the registry — no second text.startsWith.
Mark every injection and a one-shot notice gets reclaimed after compact and fed again. Mark none and compacted history cannot tell user words from a runtime note. A fork will resubmit a stale <environment_context> as user input.
Window identity, environment, abort, image resize, managed developer instructions — later you need to claim them, diff them, decide whether to re-inject after compact, so they carry begin and end. One-shot notices — approved prefix, network-rule enrollment, a leftover-token reminder — have type_markers() return two empty strings. Default matches_text is always false.
The match only looks at the two ends of the whole text. Trim the start and compare the prefix, trim the end and compare the suffix, ASCII case-insensitive; both must hit. Whatever sits in the middle does not matter. An unmarked fragment gives up reversibility on purpose.Source: codex-rs/context-fragments/src/fragment.rs lines 89–103
The developer side has a second prefix table in event_mapping.rs, to patch some unmarked claims. Coverage is narrower than the user-side matcher list. The prefix table still keeps the old <token_budget> tag; the comment says it is there to claim wrappers persisted by older versions. Once a marker is written into a rollout, it is part of the restore contract. Changing a tag is changing the protocol.
UserInstructions has a spot that is easy to miss. Its begin marker is the Markdown heading # AGENTS.md instructions; the end marker is </INSTRUCTIONS>. The protocol also has the <user_instructions> pair — this struct does not use it. Write matches_text against the protocol constant and you will miss the text the repo actually renders.Source: codex-rs/core/src/context/user_instructions.rs lines 18–19; codex-rs/protocol/src/protocol.rs lines 112–113
What must be claimed later becomes protocol; a one-shot gives it up, and the type says so. What compact must reclaim forces begin and end. A one-shot notice may be unmarked, but the comment must say reversibility was given up.
Places casually push strings; order is a convention; standing alone lives in a comment. TokenBudgetContext gets squeezed into the same developer message as permission copy. The compact filter cannot process them per message. Mix them and rollback cannot split them. A comment in event_mapping.rs admits: build_initial_context may bundle a contextual fragment with durable developer text.Source: codex-rs/core/src/event_mapping.rs lines 69–71
First assembly happens in Session::build_initial_context_with_world_state. It bins fragments by role(), the start of markers(), and requires_separate_message(): mergeable developer spans, developer spans that must stand alone, user spans, and special spans that must sit at the top or bottom.
Window identity is a little special. When Feature::TokenBudget is on and the model has a context window, it is pushed into the separate bin before the world_state loop. Output order: one merged developer message, then each standalone developer message, then one contextual user message. The sort key is the type fields.Source: codex-rs/core/src/session/mod.rs lines 3630–3728
requires_separate_message() also puts “can it share a developer message” on the type. TokenBudgetContext, ImageResizeNotice, and ManagedDeveloperInstructions choose to stand alone. The cost is an extra message and an extra role switch. The gain: the compact filter can process them per message.
The assembly door only takes registered types. That is the usual shape for keeping unregistered injections outside. Types stop the unregistered; they do not stop a registered type that stuffs 40KB every turn. That layer is review rules — next lesson.
Where the gate sits: DeepSeek Harness reconciles at request time
DSH writes it at line 107 of the repo-root AGENTS.md. The Chinese principle folds to “model-visible means already logged.” Everything that reaches a model request must be rebuildable from the session log; a new model-visible input needs a new session event.
The runtime face is invariant.ts. It hangs a listener on llm/stream, derives the expected value from the session log via deriveMessages(), then JSON.stringify-compares it with the outgoing options.messages. Mismatch: fail. The gate sits at the crash point, so it can catch drift that only appears at runtime — messages rewritten after assembly. Turn off the invariants service and the gate is gone.
Different sources of truth: the event log, or a closed set of types
DSH can live without a fragment trait because its source of truth is the event log; messages are a projection. Codex first folds anything that can appear in context into a closed set of types, then uses markers to claim them later.
The costs differ. Codex types stop you stuffing into render_full without a trait impl; they do not stop a dynamic string format!ed inside the impl. matches_text claims a text shape. Both sides pay for traceability: one pays an assertion on every request, the other pays type friction on every new injection.
Which stretch will you miss
The begin marker on UserInstructions is # AGENTS.md instructions; the protocol constant is <user_instructions>. If someone writes matches_text against the protocol constant, which actually-rendered text in the repo will they miss?
One step further: turn on the approved prefix in the demo above. After compact, can default matches_text still claim it? Which table on the developer side patches that cut, and will the compiler complain if you change the wrong constant?