The transcript admits only sendable messages
Every message committed to a session’s event log
serialises to a request every supported provider accepts. The event log is
the single source of truth for the next request; once a malformed or empty
message is committed, it is re-serialised on every subsequent round-trip, so a
single bad commit can wedge the whole run on a strict backend (Anthropic 400s,
the run dies — misclassified as fatal). The protocol state machine
(`event.rs`) enforces role alternation; this invariant
enforces per-message admissibility at the one place messages enter the log:
the apply commit boundary in agent.
Three commit-time obligations, all in Session::apply (the deep-review X-tags):
- Tool-call arguments are objects (X2). A
ContentPart::ToolCallwhosefn_argumentsis not a JSON object is repaired to{}byadmit_assistantbeforeappend_assistant. genai’s Anthropic adapter repairs only anullargument, so a bare string / number / array re-serialises verbatim and strict backends reject every later request carrying it. - No empty assistant message (X7). An assistant turn with no substantive
part — no tool call, no non-empty text, no binary — would serialise as
"content": [](or an empty text block) and is rejected once a later message (the empty-turn nudge’s user prompt) makes it non-final.admit_assistantsubstitutes a short stub; the turn still surfaces asEmptyso the nudge recovers it. - A
MaxTokensturn with captured tool calls dispatches, not strands (X6). ReturningTruncatedthere leaves the assistant message carryingtool_idsand the session inAwaitingToolResults, where the nudge’s nextappend_userfails “tool results pending”.applyinstead dispatches the captured calls and continues the loop; only aMaxTokensturn with no tool call raisesTruncatedto nudge.
Two adjacent obligations keep the invariant whole:
- Auto-compaction runs where it can (X1). Compaction needs
ReadyForUser(can_compact), which holds at the top ofapply, never mid-loop inAwaitingAssistantAfterToolResults— the prior placement was dead code. - A JSON-body 4xx is classified, not lost (X3).
parse_4xx_statusmatches both thestatus: <code>token and a JSON"code": <code>body, so an OpenRouter-shaped{"code":400,…}becomes a structuredApierror rather than an opaqueOther.
The hard rule: a new shape of committed message must pass through the same admission step — never append an assistant message (or a tool result) the serialiser would render in a form a supported provider rejects. This is the exarch analogue of the wire-hop discipline (exhaustive maps): the seam admits only validated, complete values.
See also turn-ends-ready (the role-alternation
half), agent (apply, admit_assistant, compact),
provider (from_genai, parse_4xx_status).