State composition becomes adapter-owned: ReadChat(runID, runtimeDir, transcriptPath) replaces ReadTranscript, PendingDialog/BlockedState leave the LiveSignals seam, and core keeps run lifecycle only¶
Decision from the 2026-07-10 grill on issue #62's design (full decision record on #62), filed as issue #92. Conversational state has been composed on two floors: the adapter derives a state from its transcript (claudecode.deriveState — a tail guess; codex.deriveState — rollout lifecycle events), then core's chat.Service.applyLiveSignals (internal/chat) overlays the provider's own spool signals — the LiveSignals PendingDialog and BlockedState reads ADR-0020 shipped — with core-owned precedence rules. Core is second-guessing the adapter using the adapter's own signals: the precedence table encodes claude-code's hook-spool semantics (compat §9) in generic code, and every future provider quirk would grow it.
The codex adapter (issue #87 / ADR-0037) showed the right altitude by accident of having no spool at all: it composes state from its agent's native lifecycle events (task_started/task_complete/turn_aborted), and core has nothing to add. This ADR makes that the rule: the adapter owns "what state is my agent in", composed from whatever its agent's best signals are; core owns run lifecycle only (the StateEnded override from the run's terminal outcome, transcript identity). "Liveness" never becomes a seam concept — issue #62's claude-code hook-edge liveness lands later as a purely adapter-private feature, and core works identically with any adapter. The refactor is behavior-neutral: no state-semantics change, no API payload change (pending_dialog, transcript_id, and the five-state vocabulary are byte-identical on the wire).
The decisions, pinned:
- Seam signature.
ReadTranscript(path string) (Chat, error)becomesReadChat(runID, runtimeDir, transcriptPath string) (Chat, error). The adapter reads the transcript attranscriptPathplus any adapter-private live signals spooled underruntimeDirforrunID, and returns the composed state. ADR-0026's clear/epoch obligation transfers verbatim: reading the post-clear identity yields the fresh conversation with seq restarted. transcriptPathmay be""— an active run whose transcriptLocateTranscripthas not yet hit: the adapter must still consult its live signals (a pending dialog can exist before the transcript is located — today core'schat.Service.Readperforms this pre-transcript spool consult) and otherwise returns an idle empty chat, never an error. A non-empty path that no longer exists returnsErrTranscriptGone, unchanged.runtimeDirmay be""— no runtime dir configured, or the caller wants a transcript-only read: live signals are off and the read degrades to the transcript alone (the same gate core'sliveSignals()resolver applied). Core reads ENDED runs this way, which makes a terminal state contaminated by spool residue impossible by construction — an ended run's read never sees a spool, rather than seeing one and needing a suppression rule.Chat.PendingDialogis the adapter's side channel.provider.ChatgainsPendingDialog *Dialog, moved fromchat.View; when set,StateisStateQuestion. It stays deliberately OUT ofMessages: the message stream remains purely transcript-derived, so seq numbers stay reparse-stable when the realtool_useretro-flushes — the #89/#90 invariant preserved verbatim, just owned by the adapter now. A pending dialog the transcript itself shows (the dormant flushed-tool_usefallback) stays inMessagesand does not populate the field.PendingDialogandBlockedStateleave theLiveSignalsinterface and become claudecode-private functions, called inside itsReadChatwith the precedence core applied — internalized verbatim: (1) live spool dialog →StateQuestion+Chat.PendingDialog; (2) else a transcript-flushed dialog stands on its own — the dormant fallback; (3) else a live blocked marker →StateNeedsInput; (4) else the transcript-derived state.deriveStateand the spool readers collapse into one private composition path.LiveSignalsnarrows to the lifecycle plumbing lab genuinely owns:Setup(arming the signal channel at spawn — lab writes the settings file and appends the spawn args),SpoolSig(the cheap change digest so the tailer notices a dialog while the transcript is byte-frozen),SweepSpools(runtime-dir GC against the active keep-set). All three unchanged. What the signals MEAN is no longer a seam concept — ADR-0020's spool protocol and hook coupling (compat §9) stand exactly as shipped; only their interpretation moved floors, frominternal/chatinto the adapter.- Core collapses.
chat.Service.Readand the tailer callReadChat;applyLiveSignalsand theliveSignals()resolver are deleted — no core code interprets a provider's signals.ViewkeepsTranscriptIDand theStateEndedoverride for terminal runs (lifecycle, not signals) and forwardsChat.PendingDialog, so the HTTP payload shape is unchanged. - Adapters. codex's
ReadChatignoresrunID/runtimeDir— a pure rollout fold, exactly its ADR-0037 shape (the exportedParseTranscriptcompat-test entry point is untouched). Theprovidertestfake migrates and keeps itsLiveSignalslifecycle methods. - Conformance gains a hermetic read-chat obligation. Tier 1 (
providertest.Conformance, ADR-0036) drivesReadChatthrough the existing registration table for every adapter: the empty-transcriptPathidle contract and theErrTranscriptGonesentinel are CI-executable against a hermetically-constructed adapter. The transcript grammar — what a real CLI actually writes — stays Tier-2/live, on ADR-0036's boundary. - Regression guards, all behavior-preserving: the #89/#90 pending dialog survives mid-window transcript writes and refetch churn with reparse-stable seqs; the dormant fallback yields
StateQuestionwith no spool present; a pre-transcript pending dialog still surfaces; an ended run readsStateEndedregardless of any spool residue.
Status¶
Accepted. Amends ADR-0016 and ADR-0026 (the seam's read method: ReadTranscript becomes ReadChat, and state derivation moves from the tailer/core into the adapter — read-through itself, the universal schema, and the clear/epoch obligation stand unchanged). Amends ADR-0020 (dialog detection no longer overlays in the chat service; LiveSignals narrows to Setup/SpoolSig/SweepSpools — the hook contract, spool protocol, and compat §9 coupling stand exactly as shipped, only their interpretation moved into the adapter). No schema change, no API change. Resolves #92. Unblocks #62 — its adapter-private liveness needs runID+runtimeDir at read time to see its markers, and lands with zero core change; the runtime dead-session sweep (#93) is filed separately.
Considered options¶
- Keep core's overlay and add liveness signals to the seam for #62. Rejected: it grows a core-owned precedence table with every provider quirk, and core keeps interpreting provider signals it doesn't own — the exact inversion this ADR removes. Codex already proves an adapter composes complete state from native signals with nothing for core to add; the overlay was claude-code's composition living on the wrong floor, not a generic capability.
- Surface the pending dialog as a synthetic message instead of a side-channel field. Rejected: ADR-0020 pinned the message stream as purely transcript-derived precisely so seq numbers stay reparse-stable — a synthetic message would have to be un-said when the retro-flushed
tool_uselands at a real seq, which is the #89/#90 churn class the side channel exists to prevent. - Pass richer run/store types through the seam instead of three strings. Rejected: it couples every adapter to core's store shapes for values only one adapter uses.
runID,runtimeDir,transcriptPathare the complete read-time identity; codex needs one of the three, and a run row or store handle on the seam would drag core types into packages that must stay core-free. - Leave
BlockedStateon the seam "for reuse" by a future provider. Rejected: no second consumer exists, and codex — the second provider — proves native lifecycle signals need no spool at all. A seam method with one caller that is really one provider's private read is speculative surface; if a future adapter spools a blocked marker, it interprets it inside its ownReadChatlike claude-code does.
Consequences¶
internal/provider:ReadChat(runID, runtimeDir, transcriptPath)replacesReadTranscriptonAgentProvider;Chatgains thePendingDialogside-channel field;LiveSignalsis three methods (Setup,SpoolSig,SweepSpools).internal/chat:applyLiveSignalsand theliveSignals()resolver are gone;Readand the tailer callReadChat;ViewkeepsTranscriptID+ theStateEndedoverride and forwardsPendingDialog. Core stays grep-clean of concrete provider references (neutrality_test.gounchanged).internal/provider/claudecodeowns one private composition path (transcript derivation + spool reads + the precedence, formerly split across two packages);internal/provider/codexmigrates signature-only, and theprovidertestfake composes its scripted signals adapter-style so core tests exercise the forwarding, not the composition.- Tier-1 conformance (
internal/provider/providertest/conformance.go) exercisesReadChat's hermetic contract for every registered adapter; both current adapters pass through the existing registration table. -
62 becomes an adapter-private change: claude-code's hook-edge liveness reads its own markers under
runtimeDirinsideReadChat, and no generic code learns the word "liveness".¶