Skip to content

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) becomes ReadChat(runID, runtimeDir, transcriptPath string) (Chat, error). The adapter reads the transcript at transcriptPath plus any adapter-private live signals spooled under runtimeDir for runID, 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.
  • transcriptPath may be "" — an active run whose transcript LocateTranscript has not yet hit: the adapter must still consult its live signals (a pending dialog can exist before the transcript is located — today core's chat.Service.Read performs this pre-transcript spool consult) and otherwise returns an idle empty chat, never an error. A non-empty path that no longer exists returns ErrTranscriptGone, unchanged.
  • runtimeDir may 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's liveSignals() 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.PendingDialog is the adapter's side channel. provider.Chat gains PendingDialog *Dialog, moved from chat.View; when set, State is StateQuestion. It stays deliberately OUT of Messages: the message stream remains purely transcript-derived, so seq numbers stay reparse-stable when the real tool_use retro-flushes — the #89/#90 invariant preserved verbatim, just owned by the adapter now. A pending dialog the transcript itself shows (the dormant flushed-tool_use fallback) stays in Messages and does not populate the field.
  • PendingDialog and BlockedState leave the LiveSignals interface and become claudecode-private functions, called inside its ReadChat with 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. deriveState and the spool readers collapse into one private composition path.
  • LiveSignals narrows 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, from internal/chat into the adapter.
  • Core collapses. chat.Service.Read and the tailer call ReadChat; applyLiveSignals and the liveSignals() resolver are deleted — no core code interprets a provider's signals. View keeps TranscriptID and the StateEnded override for terminal runs (lifecycle, not signals) and forwards Chat.PendingDialog, so the HTTP payload shape is unchanged.
  • Adapters. codex's ReadChat ignores runID/runtimeDir — a pure rollout fold, exactly its ADR-0037 shape (the exported ParseTranscript compat-test entry point is untouched). The providertest fake migrates and keeps its LiveSignals lifecycle methods.
  • Conformance gains a hermetic read-chat obligation. Tier 1 (providertest.Conformance, ADR-0036) drives ReadChat through the existing registration table for every adapter: the empty-transcriptPath idle contract and the ErrTranscriptGone sentinel 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 StateQuestion with no spool present; a pre-transcript pending dialog still surfaces; an ended run reads StateEnded regardless 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_use lands 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, transcriptPath are 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 BlockedState on 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 own ReadChat like claude-code does.

Consequences

  • internal/provider: ReadChat(runID, runtimeDir, transcriptPath) replaces ReadTranscript on AgentProvider; Chat gains the PendingDialog side-channel field; LiveSignals is three methods (Setup, SpoolSig, SweepSpools).
  • internal/chat: applyLiveSignals and the liveSignals() resolver are gone; Read and the tailer call ReadChat; View keeps TranscriptID + the StateEnded override and forwards PendingDialog. Core stays grep-clean of concrete provider references (neutrality_test.go unchanged).
  • internal/provider/claudecode owns one private composition path (transcript derivation + spool reads + the precedence, formerly split across two packages); internal/provider/codex migrates signature-only, and the providertest fake composes its scripted signals adapter-style so core tests exercise the forwarding, not the composition.
  • Tier-1 conformance (internal/provider/providertest/conformance.go) exercises ReadChat'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 runtimeDir inside ReadChat, and no generic code learns the word "liveness".