ADR: Streaming `RuntimeAdapter` port extension
Status
Implemented (2026-07-12) — the execute_streaming port method, the
StreamEvent contracts type, deterministic mock replay, the
execute_contract(stream) application flag, and the CLI --stream / MCP
stream surfaces have all landed, covered by
crates/templiqx-conformance/tests/streaming.rs. Superseded the prior
“design only” status. A live streaming proof against a real host adapter is
still outstanding (R12: “Partially — live streaming proof needs host”).
Context
RuntimeAdapter (templiqx-ports) is a single synchronous, non-streaming
method:
pub trait RuntimeAdapter: Send + Sync {
fn descriptor(&self) -> AdapterDescriptor;
fn execute(&self, request: &ExecutionRequest) -> Result<ExecutionReceipt, PortError>;
}
templiqx-mock and templiqx-runtime-langfuse both implement execute as a
single request/response round trip: build the interaction, call the model
once, validate and fingerprint the output, return one ExecutionReceipt.
Real chat-completion and agent-runtime APIs commonly stream partial output
(token deltas, tool-call fragments) before the final response. R9 already
names streaming as adapter-scope, host-owned, outside the deterministic
fake/conformance adapter. This ADR specifies how a streaming adapter plugs
into the same port without breaking execute or the deterministic mock.
Decision
-
Additive trait method with a default, not a breaking signature change.
RuntimeAdaptergains a second method with a default implementation that falls back toexecute, so every existing implementor (templiqx-mock,templiqx-runtime-langfuse, any host adapter) keeps compiling unchanged:pub trait RuntimeAdapter: Send + Sync { fn descriptor(&self) -> AdapterDescriptor; fn execute(&self, request: &ExecutionRequest) -> Result<ExecutionReceipt, PortError>; /// Optional streaming path. Default forwards to `execute` and emits /// the whole receipt as a single terminal event — adapters that don't /// stream need no code change. fn execute_streaming( &self, request: &ExecutionRequest, sink: &mut dyn FnMut(StreamEvent), ) -> Result<ExecutionReceipt, PortError> { let receipt = self.execute(request)?; sink(StreamEvent::Complete(receipt.clone())); Ok(receipt) } } -
StreamEventis a new, minimal contracts type — not a new receipt shape.#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields, tag = "kind")] pub enum StreamEvent { Delta { text: String }, ToolCallDelta { name: String, arguments_fragment: String }, Complete(ExecutionReceipt), Failed { code: String, message: String }, }The final
Completeevent always carries the sameExecutionReceiptthat a non-streamingexecutecall would have produced — fingerprints,output_schema_valid, andoutputare computed identically regardless of whether the caller streamed. Streaming is a transport/observability concern layered over the same deterministic result contract, not a second output format. -
Callback sink, not an async stream type. The port crate has no async runtime dependency today (
executeis synchronous). AFnMut(StreamEvent)callback keeps the trait sync and dependency-free; a host adapter that wraps a real async streaming API (SSE, websockets) drives the callback from its own executor and blocksexecute_streaminguntil the terminal event, exactly astempliqx-runtime-langfuse::executealready blocks on its HTTP call. -
Mock replays fixture deltas deterministically (implemented deviation). The design originally left
templiqx-mockon the default method. As built,ScriptedRuntimedoes overrideexecute_streaming: for a scenario with aneventsfixture it emits each fixturedeltaas a contractsStreamEvent::Delta, then callsexecuteand emits the terminal event —Complete(receipt)on success orFailed { code, message }on error. The terminalCompletestill carries the exact receiptexecuteproduces, so fingerprint parity holds and CRM3 golden receipts stay untouched. The mock’s own fixture-lifecycle enum was renamedStreamEvent→ScenarioStreamEventto free the canonical name for the contracts type; scenario JSON tags are unchanged.
Consequences
- Adding a real streaming adapter (host-owned, e.g. an OpenAI-compatible SSE
client) requires zero changes to
templiqx-contracts’sExecutionReceipt,templiqx-application, the CLI, or the MCP surface — only a newexecute_streamingoverride in that adapter crate. templiqx-cli/templiqx-mcpcan add an opt-in--streamflag later that callsexecute_streamingwith a sink that writesDeltaevents to stdout/stderr as they arrive, falling back to today’s behavior when the active adapter doesn’t override the default.- No change to
PortError,AdapterDescriptor, or boundary rules — streaming stays entirely within the existingtempliqx-ports/ adapter split.
Alternatives considered
- Separate
StreamingRuntimeAdaptertrait. Rejected — forces every caller (CLI, MCP, application service) to special-case two adapter kinds. A default-method extension keeps one trait, one capability surface. async fn/Streamreturn type. Rejected for this slice — would pull an async runtime intotempliqx-ports, which today has none, and none of the current synchronous callers (CLI, conformance harness) need it.- Encode partial deltas into
ExecutionReceiptitself. Rejected — would make the receipt shape depend on whether streaming was used, breaking the “same outcome for humans and agents, same artifact” invariant (R11, human-agent outcome parity).
Resolved questions
StreamEvent::FailedvsPortError: a mid-stream failure does both — it emits oneStreamEvent::Failed { code, message }(a stableTQX_*code, so streaming observers see a typed terminal event) and returns the underlyingPortErrorfromexecute_streaming. TheResultremains the authoritative outcome; theFailedevent is the streaming projection of it.
Live-provider boundary
templiqx-runtime-langfuse deliberately retains the default one-Complete
streaming projection. OpenAI-compatible SSE event framing, cancellation, and
tool-call assembly are provider wire concerns, not a Langfuse concern and not
part of the provider-neutral port. Adding them here would silently turn the
reference observability adapter into the default provider contract.
A host that owns a concrete provider may override execute_streaming and must
prove delta ordering, tool-call fragment assembly, exactly one terminal event,
failure projection, cancellation, response-size bounds, and parity with
execute. Until such an adapter and live credentials exist, R12’s live stream
proof remains host-gated. Local loopback tests prove the default terminal
fallback and receipt fingerprint parity without claiming live SSE readiness.