Status: Accepted for development (lifecycle sections superseded)
Implements: REQUIREMENTS.md R-000 through R-004
Architecture: TRANSACTION_RUNTIME_DESIGN.md
Delivery: TRANSACTION_RUNTIME_DELIVERY_PLAN.md
Lifecycle / admission / callback / shutdown / task-ownership: superseded by
TRANSACTION_RUNTIME_V2_SPEC.md (D-003).
Preserve non-lifecycle contracts here until explicitly migrated; do not treat
v1 lifecycle implementation claims as release-proven.
This document fixes the contracts and implementation choices that a developer would otherwise have to invent. Lifecycle ownership is defined by Runtime v2; remaining sections stay normative until their migration stage.
- Monoloop remains exactly three product components:
Connector -> Interpreter -> Loop. - Component 3 has two internal layers:
TransactionRuntime, which composes and owns one complete transaction; andLoopRuntime, the existing complete-unit/tool-reaction state machine.
- These layers may live in
monoloop-loop. They are not a fourth component. - The first direct-LLM dialect is OpenAI Chat Completions v1, streaming over HTTP using SSE. OpenAI Responses is a separate later dialect.
- Submission is synchronous and bounded. Progress, event delivery, tool execution, and completion are asynchronous.
- Production completion is push-based through an asynchronous one-shot callback. The test-kit may retain awaitable handles internally.
- A second transaction for the same active
SessionKeyis rejected immediately and is never queued. TransactionIdidentifies one admitted transaction.SessionIdis the caller-visible session/correlation identity.ExternalSessionIdis the authoritative identity created by an external agent.- Direct-LLM sessions are ephemeral routing identities only. They imply no provider-side state or Monoloop history.
- The first canonical input supports ordered typed messages containing text, assistant tool calls, and correlated tool results. Monoloop validates and encodes them but never authors or rewrites them.
- The request selects tools by stable
ToolId. Definitions and linked handlers come only from the immutable host registry. - Direct-LLM tools execute through
LoopRuntime. MCP calls and Loop tool calls use the same resolved tool set, validator, dispatcher, and linked handler. - MCP uses a maintained protocol implementation, initially the official Rust
MCP SDK (
rmcp) with Streamable HTTP on a loopback listener. Do not hand-roll a partial JSON-RPC protocol. - The runtime is entirely in-memory. It has no recovery, retry queue, session database, event journal, or callback persistence.
- Session exclusion and session-directed control use
SessionKey { channel_id, session_id }; provider session strings are not assumed globally unique. - Every provider request/response cycle has a distinct
ExchangeId,ConnectionId, andInterpretationId. - MCP capability URLs are transaction-specific and revoked at terminal state. A persistent session never reuses one capability across transactions.
- Transaction-owned tools must support bounded termination, either directly or through a killable isolation boundary.
monoloop-contracts
^
+-- monoloop-connector
+-- monoloop-interpreter
+-- monoloop-loop
^
+-- connector profile crates
monoloop-testkit -> all product crates
monoloop-loop may depend on the abstract Connector and Interpreter crates to
compose them. It must not depend on a concrete external-agent profile crate.
Concrete Channel bindings are assembled by the host from implementations of
the contracts below.
The transaction coordinator may invoke an outbound encoder, Connector,
Interpreter, MCP adapter, and LoopRuntime. The inner LoopRuntime remains
provider-neutral: it does not encode dialect bytes, write Connector input, or
contain MCP transport code.
The exact module paths are fixed in §15. Public newtypes must have validating constructors and private fields. String identities and names reject empty, oversized, or control-character values.
pub struct TransactionId(Uuid);
pub struct ExchangeId(Uuid);
pub struct SessionId(String);
pub struct SessionKey {
pub channel_id: ChannelId,
pub session_id: SessionId,
}
pub struct ExternalSessionId(String);
pub struct ChannelId(String);
pub struct ToolId(String);
pub struct ToolName(String);Rules:
TransactionIdis generated by Monoloop during admission and is never reused.- For a direct LLM,
SessionIdis caller-supplied or generated during admission. - For an existing external-agent session,
SessionIdis the validated external ID supplied by the caller. - For a newly created external-agent session, no
SessionIdexists until the external system returns its authoritative ID. The first ordinary event isSessionEstablished; earlier diagnostics are buffered. MonoloopRunIdremains an internal component correlation ID and is derived one-to-one fromTransactionIdfor this runtime.- Every child completion carries
TransactionId. Session identity alone is insufficient to accept a late child result. - Provider session IDs are scoped to a Channel. Registry lookup, duplicate
exclusion, MCP routing, and session-directed termination use
SessionKey. Events expose bothchannel_idand the unmodified authoritativesession_id. ExchangeIdidentifies one provider exchange inside a transaction. Every exchange allocates a freshConnectionIdandInterpretationId; late exchange results are rejected by all four identities.
For an external-agent Channel, SessionId and ExternalSessionId wrap the same
validated opaque byte string; conversion changes only the Rust type. When a
caller supplies a SessionId, the completed attachment must return an
ExternalSessionId with identical bytes before the SessionKey is installed
or any prompt is sent. A mismatch selects InvariantFailed and releases the
attachment.
pub struct CanonicalInput {
pub messages: Vec<CanonicalMessage>,
}
pub enum CanonicalMessage {
System {
content: Vec<TextPart>,
name: Option<String>,
},
User {
content: Vec<TextPart>,
name: Option<String>,
},
Assistant {
content: Vec<TextPart>,
tool_calls: Vec<CanonicalAssistantToolCall>,
},
Tool {
tool_call_id: String,
content: Vec<TextPart>,
},
}
pub struct TextPart {
pub text: String,
}
pub struct CanonicalAssistantToolCall {
pub tool_call_id: String,
pub tool_name: ToolName,
pub arguments: serde_json::Value,
}Validation:
- at least one message;
- at least one text part for
System,User, andTool; - at least one text part or assistant tool call for
Assistant; - all strings and aggregate encoded bytes bounded by
InputLimits; - tool-call IDs are unique within the canonical input;
- every
Tool.tool_call_idrefers to a preceding assistant tool call; - assistant tool names and argument JSON satisfy the same name, depth, and byte bounds used by live tool calls;
nameis bounded and available only where declared above;- no empty text part;
- role order is preserved exactly;
- no trimming, interpolation, system-message insertion, or prompt rewriting.
This representation can losslessly encode caller-supplied historical assistant-tool-call/tool-result sequences, including assistant messages with no text. Adding image, audio, file, or other content requires a versioned contract change and dialect qualification. Unknown content fails; it is not converted to text.
pub struct InvocationConfig {
pub model: Option<String>,
pub temperature: Option<f32>,
pub reasoning_effort: Option<ReasoningEffort>,
pub max_output_tokens: Option<u32>,
pub stop: Vec<String>,
pub response_format: Option<ResponseFormat>,
pub continuation_policy: ContinuationPolicy,
pub deadline: Option<Duration>,
pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
}
pub struct SessionConfig {
pub specialist_profile: Option<String>,
pub mode: Option<String>,
pub permission_profile: Option<String>,
pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
}
pub enum ContinuationPolicy {
InlineToolContinuation,
CallerControlled,
}
pub struct VersionedExtension {
pub version: u16,
pub value: serde_json::Value,
}ExtensionKey is a bounded namespace such as openai.seed, not an arbitrary
unqualified key. Extension depth, key count, string bytes, and total serialized
bytes are validated before admission.
SessionConfig is optional and applies only to external-agent session
creation/attachment. It contains no prompt, MCP descriptor, endpoint,
credential, or secret. Common labels and extension keys/values are bounded.
Direct-LLM Channels reject non-None session configuration. For an existing
external session, immutable settings must match the attached session's known
configuration or admission fails; no setting is silently reapplied.
Effective configuration is built once:
Channel defaults <- session configuration <- permitted invocation overrides
The merge returns a typed error for an unknown option, unsupported option, invalid numeric value, immutable session option, secret/endpoint field, or capability conflict. It never drops an option silently.
The API uses futures without requiring async_trait in public contracts:
pub type EventDelivery =
Pin<Box<dyn Future<Output = Result<(), EventDeliveryError>> + Send + 'static>>;
pub trait TransactionEventSink: Send + Sync + 'static {
fn deliver(&self, event: TransactionEvent) -> EventDelivery;
}
pub type CompletionDelivery =
Pin<Box<dyn Future<Output = Result<(), CompletionDeliveryError>> + Send + 'static>>;
pub trait CompletionCallback: Send + 'static {
fn call(self: Box<Self>, end: TransactionEnd) -> CompletionDelivery;
}The crate supplies closure adapters so callers normally pass async closures. Calling either trait method must return promptly and must not perform blocking I/O before returning its future.
Each transaction owns:
- one bounded event-delivery queue and one sequential delivery task;
- one callback reservation acquired during admission; and
- one
FinalizationGuardcontaining the callback, terminal-event route, last event sequence, and an atomic exactly-once claim.
The guard is retained in the active registry as well as by the actor. Normally the actor claims it. During forced runtime shutdown, the shutdown supervisor may claim it only after the actor has been aborted and joined. Whichever path claims it must attempt the final event, remove registry entries, and invoke the callback exactly once. Dropping an unclaimed guard is an invariant failure.
The event-delivery task and callback reservation are runtime-owned resources of
the guard, not members of the actor's abortable JoinSet. They remain usable
after actor abort and are closed only by successful guard finalization or final
runtime teardown.
Guard claim uses one atomic compare-and-swap. The winning path takes the
single-use callback and terminal route from a short std::sync::Mutex section
with no I/O and no .await; losing paths cannot observe or invoke them.
Event delivery preserves order. A delivery error or panic reports
EventDeliveryFailed. A delivery future that exceeds the transaction deadline
is cancelled. Callback futures have an independent configured deadline, are
caught for panic, and cannot alter an already selected terminal result.
pub struct TransactionRequest {
pub channel_id: ChannelId,
pub session_id: Option<SessionId>,
pub input: CanonicalInput,
pub session_config: Option<SessionConfig>,
pub invocation_config: InvocationConfig,
pub tools: Vec<ToolId>,
pub events: Arc<dyn TransactionEventSink>,
pub completion: Box<dyn CompletionCallback>,
}
pub struct AdmissionReceipt {
pub transaction_id: TransactionId,
pub session_id: Option<SessionId>,
}
pub enum TransactionSelector {
Transaction(TransactionId),
Session(SessionKey),
}
pub enum TerminationMode {
Cancel { reason: CancellationReason },
ForceTerminate { reason: TerminationReason },
}
pub struct CancellationReason {
pub code: CancellationReasonCode,
pub detail: Option<SafeDiagnostic>,
}
pub enum CancellationReasonCode {
CallerRequested,
RuntimeShutdown,
}
pub struct TerminationReason {
pub code: TerminationReasonCode,
pub detail: Option<SafeDiagnostic>,
}
pub enum TerminationReasonCode {
CallerRequested,
CancellationGraceExpired,
RuntimeShutdown,
}
pub type Shutdown =
Pin<Box<dyn Future<Output = ShutdownDisposition> + Send + 'static>>;
pub trait TransactionRuntime: Send + Sync {
fn submit(
&self,
request: TransactionRequest,
) -> Result<AdmissionReceipt, AdmissionError>;
fn terminate(
&self,
selector: TransactionSelector,
mode: TerminationMode,
) -> TerminationDisposition;
fn shutdown(&self, deadline: Duration) -> Shutdown;
}The types above belong to monoloop-contracts. Concrete startup belongs to
monoloop-loop, preventing contracts from depending on an implementation
crate:
pub type Startup =
Pin<Box<dyn Future<Output = Result<Arc<DefaultTransactionRuntime>, StartupError>>
+ Send
+ 'static>>;
pub struct RuntimeBootstrap {
pub config: RuntimeConfig,
pub channels: ChannelRegistry,
pub tools: HostToolRegistry,
pub executor: tokio::runtime::Handle,
}
impl DefaultTransactionRuntime {
pub fn start(bootstrap: RuntimeBootstrap) -> Startup;
}AdmissionReceipt.session_id is immediately present for direct LLMs and
existing external sessions. transaction_id permits termination while a new
external session is still being created.
Cancel requests cooperative child cancellation and produces Cancelled when
it wins terminal selection. ForceTerminate immediately requests each
available force/abort control and produces Terminated when it wins. Both are
idempotent; repeating either mode returns AlreadyRequested, and neither may
rewrite a terminal result already selected.
TerminationMode, both reason types, their closed code enums, and
SafeDiagnostic belong to monoloop-contracts. Details are optional,
bounded, pre-redacted text and grant no routing or authorization authority.
submit performs no network, process, filesystem, tool, or callback operation.
It validates, resolves immutable entries, reserves bounded capacity, installs
the registry entry, spawns the actor, and returns.
start is the only startup path. It validates registries and limits, binds and
qualifies the loopback MCP listener, creates bounded runtime services, and
returns only after the runtime is Accepting. Startup failure closes every
partially created service and returns a typed StartupError; a partially
started runtime is never exposed.
pub struct TransactionEvent {
pub transaction_id: TransactionId,
pub channel_id: ChannelId,
pub session_id: SessionId,
pub sequence: u64,
pub payload: TransactionEventPayload,
}
pub enum TransactionEventPayload {
SessionEstablished { external_session_id: ExternalSessionId },
CanonicalUnit(CanonicalUnitEvent),
ToolLifecycle(ToolLifecycleEvent),
Diagnostic(TransactionDiagnostic),
Ended(TransactionEnd),
}
pub struct TransactionEnd {
pub transaction_id: TransactionId,
pub session_id: Option<SessionId>,
pub channel_id: ChannelId,
pub kind: TransactionEndKind,
pub prior_terminal_cause: Option<TransactionEndKind>,
pub event_delivery: EventDeliveryOutcome,
pub emitted_events: u64,
pub usage: TransactionUsage,
pub diagnostics: Vec<TransactionDiagnostic>,
}
pub enum TransactionEndKind {
Completed,
ContinuationRequired,
Cancelled,
Terminated,
RuntimeShutdown,
DeadlineExceeded,
ChannelOpenFailed,
EncodingFailed,
ConnectorFailed,
InterpretationFailed,
ToolExchangeFailed,
EventDeliveryFailed,
LimitExceeded,
InvariantFailed,
}
pub enum EventDeliveryOutcome {
Accepted,
Failed,
}session_id is optional only when a new external session fails or is terminated
before the external system creates its authoritative ID. No admitted
transaction is relabelled AdmissionInvalid; admission failures are synchronous
and have no event or callback.
TransactionDiagnostic is a bounded, safe code plus bounded safe message. It
must not contain prompts, tool payloads, credentials, capability tokens, raw
provider bodies, or unbounded error chains.
DefaultTransactionRuntime::submit executes these steps in order:
- reject when runtime state is not
Accepting; - validate input, configuration, requested deadline, sink, and list bounds;
- resolve
ChannelBindingbyChannelId; - validate Channel/config/tool-mode capabilities;
- resolve and deduplicate every
ToolIdinto one immutableResolvedToolSet; - compute effective configuration without I/O;
- allocate
TransactionId, direct-LLMSessionIdif needed, and the resultingSessionKeywhen the session is already known; - reserve global, per-Channel, event-delivery, and callback capacity;
- in one non-async registry critical section, reject a duplicate active
SessionKeyand installActiveTransactionwith itsFinalizationGuard; - create bounded control/data/event channels;
- spawn the actor and delivery task through the injected Tokio handle; and
- return
AdmissionReceipt.
Any failure before step 9 releases all acquired permits. Any spawn failure rolls
back the registry entry and permits before returning AdmissionError.
For a new external session, the registry first indexes the transaction by
TransactionId. When session creation returns, the actor atomically claims
SessionKey { selected channel, authoritative session ID }. Collision selects
InvariantFailed before sending the prompt, revokes any MCP capability, and
releases the external attachment.
Admission errors are closed and typed:
pub enum AdmissionErrorKind {
RuntimeShuttingDown,
UnknownChannel,
SessionAlreadyActive,
UnknownTool,
DuplicateTool,
InvalidInput,
InvalidConfiguration,
CapabilityMismatch,
CapacityExceeded,
SpawnFailed,
}Errors include only safe bounded context.
pub enum ChannelKind {
ExternalAgent,
DirectLlm,
}
pub enum ToolExecutionMode {
McpGateway,
ModelToolCalls,
None,
}
pub enum McpConfigurationCapability {
None,
CreationOnly,
Refreshable,
}
pub struct ChannelCapabilities {
pub session_mode: SessionMode,
pub mcp_configuration: McpConfigurationCapability,
pub mcp_reachability: McpReachability,
pub exchange_mode: ExchangeMode,
pub continuation_policies: BTreeSet<ContinuationPolicy>,
pub supports_distinct_session_concurrency: bool,
pub input_dialect: DialectDescriptor,
pub output_dialect: DialectDescriptor,
pub option_policy: OptionPolicy,
}
pub enum SessionMode {
Stateless,
External,
}
pub enum McpReachability {
None,
SameLoopbackNamespace,
QualifiedRemoteTransport,
}
pub enum ExchangeMode {
RequestResponse,
Bidirectional,
}
pub struct ChannelBinding {
pub id: ChannelId,
pub kind: ChannelKind,
pub tool_mode: ToolExecutionMode,
pub connector_factory: Arc<dyn ConnectorFactory>,
pub encoder: Arc<dyn OutboundDialectEncoder>,
pub interpreter: Arc<dyn InterpreterFactory>,
pub defaults: ChannelDefaults,
pub capabilities: ChannelCapabilities,
pub limits: ChannelLimits,
}The Channel registry is immutable after runtime construction. Duplicate IDs, invalid capability combinations, unsupported dialect pairs, missing session adapters, or zero/contradictory limits fail runtime construction.
Startup validates this matrix:
DirectLlmrequiresStateless, noSessionAdapter, and initiallyRequestResponse;ExternalAgentrequiresExternaland aSessionAdapter;McpGatewayrequires non-NoneMCP configuration and declared reachable transport;- non-MCP tool modes require
mcp_configuration == None; SendAndRetainis legal only forBidirectional;InlineToolContinuationrequiresModelToolCalls;- every production Channel must support concurrent distinct sessions, although its configured semaphore may bound the count; and
- encoder, Connector, and Interpreter dialect declarations must match exactly.
pub struct ConnectorInstance {
pub instance_id: ConnectorInstanceId,
pub connector: Arc<dyn Connector>,
pub sessions: Option<Arc<dyn SessionAdapter>>,
}
pub trait ConnectorFactory: Send + Sync {
fn create(&self) -> Result<ConnectorInstance, ConnectorBuildError>;
}
pub struct SessionAttachment {
pub owner: ConnectorInstanceId,
pub external_session_id: ExternalSessionId,
pub effective_session_config: SessionConfig,
pub route: Arc<dyn SessionRoute>,
}
pub trait SessionRoute: Send + Sync {
fn owner(&self) -> &ConnectorInstanceId;
}
pub trait OutboundDialectEncoder: Send + Sync {
fn encode_initial(
&self,
request: InitialEncodeRequest<'_>,
) -> Result<EncodedExchange, EncodingError>;
fn encode_tool_continuation(
&self,
request: ToolContinuationEncodeRequest<'_>,
) -> Result<EncodedExchange, EncodingError>;
}
pub trait InterpreterFactory: Send + Sync {
fn supports(&self, dialect: &DialectBinding) -> SupportLevel;
fn start(
&self,
request: StartInterpretation,
) -> Result<Interpretation, InterpreterError>;
}
pub struct InitialEncodeRequest<'a> {
pub transaction_id: &'a TransactionId,
pub exchange_id: &'a ExchangeId,
pub input: &'a CanonicalInput,
pub config: &'a EffectiveConfig,
pub tools: &'a [ToolSpec],
}
pub struct ToolContinuationEncodeRequest<'a> {
pub transaction_id: &'a TransactionId,
pub exchange_id: &'a ExchangeId,
pub context: &'a ContinuationContext,
pub results: &'a [CanonicalToolResult],
pub config: &'a EffectiveConfig,
pub tools: &'a [ToolSpec],
}
pub struct EncodedExchange {
pub bytes: bytes::Bytes,
pub required_input_dialect: DialectDescriptor,
pub input_policy: ExchangeInputPolicy,
}
pub enum ExchangeInputPolicy {
SendAndFinish,
SendAndRetain,
}
pub trait SessionAdapter: Send + Sync {
fn begin_attach(
&self,
request: SessionAttachRequest,
) -> Result<PendingSessionAttachment, SessionAttachError>;
fn begin_refresh_mcp(
&self,
attachment: Arc<SessionAttachment>,
descriptor: Option<McpServerDescriptor>,
) -> Result<PendingSessionConfiguration, SessionConfigurationError>;
}
pub struct McpServerDescriptor {
pub server_name: String,
pub protocol_version: String,
capability_url: secrecy::SecretString,
}
pub struct SessionAttachRequest {
pub transaction_id: TransactionId,
pub channel_id: ChannelId,
pub requested_session_id: Option<SessionId>,
pub session_config: SessionConfig,
pub initial_mcp: Option<McpServerDescriptor>,
pub deadline: Instant,
}
pub trait PendingOperationControl: Send + Sync {
fn cancel(&self) -> ControlDisposition;
fn force_terminate(&self) -> ControlDisposition;
}
pub struct PendingSessionAttachment {
pub control: Arc<dyn PendingOperationControl>,
pub completion: SessionAttachmentCompletion,
}
pub struct PendingSessionConfiguration {
pub control: Arc<dyn PendingOperationControl>,
pub completion: SessionConfigurationCompletion,
}The encoder trait and request value types belong to monoloop-contracts.
InitialEncodeRequest contains canonical input, effective configuration, and
&[ToolSpec]; it does not reference the live ResolvedToolSet or handler
objects from monoloop-loop. ResolvedToolSet::specs() supplies that exact
ordered slice, preserving one canonical definition without reversing crate
dependencies.
ContinuationContext is an immutable bounded canonical message sequence
containing the original caller input plus prior assistant tool calls and tool
results required by the selected dialect. The actor constructs it mechanically
from accepted canonical events/results; the encoder cannot fetch hidden
history. SendAndFinish is required for HTTP request/response exchanges;
SendAndRetain is allowed only for a Channel that declares a qualified
bidirectional exchange.
ConnectorFactory, ConnectorInstance, SessionAdapter, SessionAttachment,
and SessionRoute belong to monoloop-connector, not
monoloop-contracts. Runtime startup creates exactly one
ConnectorInstance for each Channel binding. External-session attachment and
all opens for that Channel use the SessionAdapter and Connector from that
same instance.
PendingSessionAttachment contains immediate cancellation control and exactly
one completion future. PendingSessionConfiguration likewise has immediate
control and one completion future; Some(descriptor) installs the current
transaction capability and None removes Monoloop MCP configuration when the
profile supports removal. OpenConnection gains an optional
Arc<SessionAttachment> and the Connector rejects an attachment whose
owner differs from its own instance ID. The opaque route is the only local
routing authority; copying an external session string into another Connector
instance does not authorize attachment.
The SessionAdapter normalizes and validates requested session configuration and
returns the effective immutable value on SessionAttachment. If an existing
provider session cannot report or verify a requested setting, that setting is
unsupported for attach and fails explicitly.
Direct-LLM Channels require ConnectorInstance.sessions == None.
External-agent Channels require Some(SessionAdapter). These combinations are
validated at startup. EncodedExchange is bounded bytes plus an explicit
exchange policy; it contains no transport credentials.
SessionAttachRequest carries an optional initial MCP descriptor. A new
external session therefore receives its transaction capability as part of
session creation when the protocol requires mcpServers at creation time.
Existing sessions use begin_refresh_mcp.
McpServerDescriptor belongs to monoloop-connector. Its capability URL is
secret-bearing: the type implements only redacted Debug/display behavior and
exposes the URL solely to the selected SessionAdapter's serialization method.
Server name, protocol version, and total serialized descriptor bytes are
bounded. SessionAttachRequest contains no prompt or invocation-level model
configuration.
The InterpreterFactory definition above is the existing
monoloop-interpreter port. Every ExchangeDriver supplies a fresh
StartInterpretation containing its new interpretation/connection IDs, frozen
dialect binding, external session identity, and limits.
Channel capabilities declare None, CreationOnly, or Refreshable MCP
configuration. CreationOnly permits a tool-enabled transaction only while
creating a new external session; because the descriptor cannot later be rotated
or removed, that resulting attachment is not eligible for another Monoloop
transaction. Existing-session admission fails CapabilityMismatch.
Refreshable is required for request-scoped tools across session reuse.
Both pending completion futures resolve exactly once to success or a typed failure. Dropped senders become invariant failures, not implicit cancellation. Their controls are available before any I/O can block and are invoked by both transaction termination modes.
Transaction-level ordering is fixed, not profile-selected:
DirectLlm:
open exchange -> send
ExternalAgent:
prepare pending MCP capability when applicable
-> attach/create/load session
-> claim SessionKey
-> activate/refresh MCP when applicable
-> open exchange with the returned SessionAttachment
-> send
An adapter may internally start a process or transport while attaching, but it
still returns one attachment before transaction-level begin_open.
The one ConnectorInstance per Channel is explicitly concurrent. Its
Connector and SessionAdapter must accept operations for distinct
SessionKeys concurrently up to Channel limits. A profile may use bounded
internal semaphores/queues, but it may not serialize unrelated sessions behind
an async mutex held across provider I/O. Same-SessionKey concurrency is rejected
by admission before the instance is called.
Each transaction has one TransactionActor that exclusively owns mutable
transaction state. It is not stored behind an async mutex.
TransactionActor
TransactionId
SessionKey?
phase
ChannelBinding
EffectiveConfig
ResolvedToolSet
active ExchangeState map
inner LoopRuntime handle when tool mode is ModelToolCalls
continuation context
FinalizationGuard/EventSequencer handle
terminal selection?
JoinSet of owned child tasks
acquired capacity permits
It has two channels:
control_rx, capacity one, for terminate/cancel; andcommand_rx, bounded bymax_actor_commands, for child results.
The run loop uses tokio::select! { biased; ... } with control first. A full
control channel means a request is already pending; it never falls back to the
ordinary command queue.
Every child command includes TransactionId; exchange commands also include
ExchangeId, ConnectionId, and InterpretationId as applicable. Commands
with stale identity, commands invalid for the current phase, and commands after
terminal selection are rejected and counted. An impossible in-scope command
selects InvariantFailed; it is not ignored.
The actor never directly awaits unbounded Connector, Interpreter, tool, MCP, or
callback work. Such work runs in the actor's JoinSet or in a runtime-owned
bounded service and reports through a command.
enum ActorCommand {
SessionAttached(SessionAttachment),
SessionAttachFailed(SessionAttachError),
ExchangeOpened {
exchange_id: ExchangeId,
connection_id: ConnectionId,
interpretation_id: InterpretationId,
},
ExchangeOpenFailed {
exchange_id: ExchangeId,
error: ConnectorError,
},
InputSent { exchange_id: ExchangeId },
InterpreterEvent {
exchange_id: ExchangeId,
interpretation_id: InterpretationId,
event: InterpreterOutputEvent,
},
ExchangeEnded {
exchange_id: ExchangeId,
connection: ConnectionEnd,
interpretation: InterpretationEnd,
},
LoopEvent(LoopOutputEvent),
LoopEnded(LoopEnd),
McpToolStarted(ToolActionId),
McpToolFinished(ToolCompletion),
EventDeliveryFailed(EventDeliveryError),
DeadlineElapsed,
ChildPanicked(ChildKind),
}
pub enum ChildKind {
SessionAttachment,
SessionConfiguration,
ExchangeDriver(ExchangeId),
InnerLoop,
ToolExecution(ToolExecutionId),
}Raw Connector bytes are deliberately absent from ActorCommand; they travel
through the bounded exchange bridge described below. Canonical event payloads
are boxed where necessary and count against event queue and canonical-unit byte
limits.
Admitted
-> EstablishingSession
-> ActivatingTools
-> OpeningChannel
-> Sending
-> Receiving
-> ExecutingTools
-> SendingContinuation
-> Receiving
-> Finalizing
-> Terminal
any nonterminal -> Cancelling -> Finalizing -> Terminal
any nonterminal -> Terminating -> Finalizing -> Terminal
any nonterminal -> Failing -> Finalizing -> Terminal
DirectLlm skips EstablishingSession; non-McpGateway Channels treat
ActivatingTools as a validated no-op. An McpGateway Channel creates its
pending capability before session creation/attachment and may leave
ActivatingTools only after SessionKey claim, descriptor
installation/refresh, and route activation succeed. No request is sent before
that point.
The fixed attach-before-open ordering for external agents is defined in §5. The actor does not infer ordering from whichever future completes first.
External-agent MCP calls may move transaction bookkeeping through
ExecutingTools, but the MCP response—not an encoded model continuation—returns
the result. Provider-observed tool events are observations and never start a
second execution.
For direct LLMs, ToolRequestReady reaches LoopRuntime, which dispatches and
returns OutboundToolResult. Under InlineToolContinuation, the coordinator
encodes the complete result and opens the next HTTP exchange within the same
transaction. That exchange receives a new ExchangeId, ConnectionId, and
InterpretationId. Under CallerControlled, the transaction finishes as
ContinuationRequired.
Each allowed (phase, command) pair is implemented as an explicit match and has
a unit test. There is no wildcard that silently ignores an in-scope command.
The first ordinary EventDeliveryFailed command selects
TransactionEndKind::EventDeliveryFailed and transitions
Failing -> Finalizing -> Terminal. Later delivery failures are diagnostics
only because terminal-cause selection is already fixed.
Every provider request/response cycle is owned by one tracked ExchangeDriver.
It owns:
ExchangeId
OpenedRawConnection
Interpretation
Connector -> Interpreter pump task
canonical distributor
connection and interpretation completion tasks
The pump performs:
RawOutputHandle.receive()
-> InterpretationInput.push_bytes()
-> on authoritative clean Connector end: finish_clean()
-> on transport failure: transport_failed()
-> on cancellation: cancel()
It never copies raw chunks through the transaction actor. Connector queued-byte limits and Interpreter undecoded/frame limits bound both sides of the pump. Backpressure is direct and no extra overflow queue exists.
Each accepted Interpreter event enters an exchange-scoped lossless distributor:
- the transaction actor receives one subscription for public sequencing; and
- only
ModelToolCallsChannels give the innerLoopRuntimea second lossless subscription.
McpGateway and None Channels do not feed provider-observed tool events into
the inner Loop, preventing duplicate execution. Loop output returns to the
actor. Distributor delivery order and gaps use the existing
CanonicalEventSubscription contract.
An exchange is complete only after both Connector and Interpretation terminal results are reconciled. The actor retains no completed exchange handles after reconciliation. Direct-LLM continuation history stores bounded canonical assistant tool-call/result material, not live exchange objects.
Terminal-cause selection is a single assignment. Once assigned, it cannot be rewritten by a later child failure. A user control command is biased ahead of ordinary queued work; otherwise the first valid terminal intent dequeued by the actor wins.
Finalization order:
- select terminal once;
- reject new data commands and MCP dispatch;
- clear active MCP run/tool binding;
- request Connector, Interpreter, Loop, and tool cancellation concurrently;
- await owned children until
cleanup_deadline; - abort remaining abortable tasks and record bounded diagnostics;
- deliver
Endedas the final event and await its acknowledgement using the independentterminal_event_delivery_deadline; - if final delivery fails, produce the callback result as
EventDeliveryFailed, preserve the selected cause inprior_terminal_cause, and record failed delivery; no claim is made that the final event reached the caller; - close the event queue;
- remove transaction and session registry entries;
- release all transaction capacity except the callback reservation;
- schedule exactly one callback invocation; and
- release callback capacity after the callback finishes, fails, panics, or reaches its deadline.
No child owns the public event sink or callback. Consequently no child can emit after terminal selection.
terminate dispositions:
pub enum TerminationDisposition {
Accepted,
AlreadyRequested,
AlreadyTerminal,
NotFound,
}Termination is valid by TransactionId during external session creation and by
either identity after session establishment.
The runtime-owned EventSequencer inside FinalizationGuard is the sole
allocator of transaction event sequence numbers. The live actor is its only
ordinary caller; after actor abort and join, the shutdown supervisor may use it
once for Ended. Sequence starts at one, is contiguous, and includes Ended.
The actor writes to a runtime-owned bounded queue, not directly to caller code. When the queue is full, it enters a backpressured send that still selects the control channel first. It does not spin, drop, or allocate an overflow buffer.
Ordinary event delivery is bounded by the remaining transaction deadline.
Terminal Ended delivery uses terminal_event_delivery_deadline from the
cleanup budget; it never reuses an already expired transaction deadline.
Canonical Interpreter events and Loop lifecycle events are composed into the transaction stream without conversion to presentation text. Internal Connector chunks, raw SSE frames, and partial tool arguments are never public events.
There is one required sink in the first implementation. Additional optional observers belong downstream of that sink or in the test-kit and cannot affect the production transaction.
pub struct ToolSpec {
pub id: ToolId,
pub name: ToolName,
pub description: String,
pub input_schema: JsonSchema,
pub output_contract: ToolOutputContract,
pub limits: ToolLimits,
pub cancellation: ToolCancellationPolicy,
}
pub struct RegisteredTool {
pub spec: ToolSpec,
pub handler: Arc<dyn ToolHandler>,
}
pub struct ToolCallContext {
pub transaction_id: TransactionId,
pub session_key: SessionKey,
pub exchange_id: Option<ExchangeId>,
pub tool_action_id: ToolActionId,
pub tool_id: ToolId,
pub deadline: Instant,
}
pub struct ToolOutputContract {
pub success: ToolSuccessContract,
pub error_data_schema: Option<JsonSchema>,
}
pub enum ToolSuccessContract {
Json { schema: JsonSchema },
Text { media_type: String },
}
pub enum CanonicalToolOutput {
Json(serde_json::Value),
Text(String),
}
pub struct CanonicalToolError {
pub code: String,
pub message: String,
pub data: Option<serde_json::Value>,
}
pub struct CanonicalToolResult {
pub transaction_id: TransactionId,
pub session_key: SessionKey,
pub exchange_id: ExchangeId,
pub tool_action_id: ToolActionId,
pub tool_id: ToolId,
pub provider_tool_call_id: String,
pub request_ordinal: u32,
pub outcome: CanonicalToolResultOutcome,
}
pub enum CanonicalToolResultOutcome {
Succeeded(CanonicalToolOutput),
DomainFailed(CanonicalToolError),
}
pub trait ToolHandler: Send + Sync {
fn start(
&self,
call: ToolCall,
context: ToolCallContext,
) -> Result<ToolExecutionHandle, ToolStartError>;
}
pub struct ToolExecutionHandle {
pub execution_id: ToolExecutionId,
pub control: ToolExecutionControl,
pub completion: ToolExecutionCompletion,
}
pub enum ToolCompletion {
Succeeded(CanonicalToolOutput),
DomainFailed(CanonicalToolError),
RuntimeFailed(ToolRuntimeError),
}
pub enum ToolCancellationPolicy {
Cooperative { grace: Duration },
Abortable,
IsolatedKillable { grace: Duration },
}ToolExecutionCompletion is consumed exactly once. It resolves to a bounded
ToolCompletion. Succeeded and DomainFailed are ordinary canonical tool
outcomes: after validation they return through MCP or become direct-LLM tool
result messages. RuntimeFailed means the linked implementation could not
produce a trustworthy declared result and selects ToolExchangeFailed.
Every tool must be Cooperative, Abortable, or IsolatedKillable.
HostToolRegistry::build rejects a handler without a bounded terminal
mechanism. Cooperative handlers that exceed grace are treated as runtime
failures; if they cannot then be aborted, they must instead run in an
IsolatedKillable execution service. Unstoppable in-process work is not a
supported tool declaration.
HostToolRegistry::build validates all entries at startup and rejects duplicate
IDs/names, invalid schemas, incompatible output declarations, and limit
violations.
Text media types, error codes/messages/data, JSON depth, and serialized output
bytes are bounded. CanonicalToolError is part of the public output contract;
it contains no unbounded internal error chain or secret diagnostic.
ToolCallContext carries correlation and deadline authority only. It never
contains the prompt, model credentials, MCP capability, raw provider body,
unrestricted runtime handle, or another transaction's tools.
CanonicalToolResult is the sole continuation/MCP success-domain-result
product. provider_tool_call_id is preserved exactly for dialect encoding,
while internal routing uses ToolActionId and ExchangeId.
request_ordinal preserves model-declared order independently of execution
completion order. Runtime failures never masquerade as this type.
Admission creates ResolvedToolSet containing:
- ordered canonical definitions;
- lookup by
ToolId; - lookup by
ToolName; and - references to the same registered handlers.
ResolvedToolSet is immutable and transaction-owned. The OpenAI encoder and MCP
tools/list project from this exact instance.
TransactionToolDispatcher is the only code that:
- validates SessionKey, transaction, and exchange identity where applicable;
- checks the resolved allowlist;
- validates JSON input schema and payload bytes;
- acquires global, transaction, and per-tool capacity;
- invokes the linked handler;
- tracks cancellation and completion;
- validates successful output against
ToolOutputContract, including encoded bytes, schema, and content-type constraints; - validates domain errors against their bounded public error contract; and
- creates canonical lifecycle/result events.
Invalid model/MCP arguments and declared domain failures produce canonical
rejected/failed tool results and do not by themselves fail the transaction.
Unknown/disallowed/stale calls fail closed at the calling protocol boundary.
Handler start failure, panic, lost completion, output-contract violation, or
failure of the bounded termination mechanism selects ToolExchangeFailed.
The existing ToolRegistry and ToolRuntime ports remain the inner
LoopRuntime boundary. ResolvedToolRegistry and HostToolRuntime implement
those ports by delegating to TransactionToolDispatcher. MCP delegates to the
same dispatcher directly. EmptyToolRegistry/NoToolRuntime remain valid for
empty-tool conformance tests, not as the advertised production tool path.
One McpGateway is created per DefaultTransactionRuntime; every admitted
McpGateway external-agent transaction receives a new gateway binding, even
when its resolved tool set is empty.
- Transport: MCP Streamable HTTP.
- Bind address: loopback only in the first implementation.
- Protocol: maintained
rmcpserver implementation. - Routing: a transaction-specific unguessable 256-bit capability token in the URL.
- Token generation: OS CSPRNG.
- Logging: token and full URL are always redacted.
http://127.0.0.1:<port>/mcp/<transaction-capability>
The runtime validates the selected rmcp/HTTP dependency against the workspace
MSRV before implementation. If the maintained SDK cannot support the current
MSRV, update the workspace MSRV through an explicit project decision; do not
replace it with an unqualified partial protocol.
Each capability starts as a disabled pending binding:
PendingMcpTransactionBinding
capability token
TransactionId
ResolvedToolSet
dispatcher route
cancellation/deadline
The pending route rejects calls as not ready. For a new external session, its
descriptor is passed in SessionAttachRequest; after the external system
returns its authoritative ID and the actor claims SessionKey, the route is
atomically activated as an immutable McpTransactionBinding. For an existing
session, activation occurs only after begin_refresh_mcp confirms descriptor
installation.
Before sending every external-agent prompt, including on a reused session, the actor therefore:
- creates a fresh pending binding and capability;
- installs it during session creation or refreshes the existing session;
- claims/validates
SessionKey; - activates the route; and
- only then sends the prompt.
Before terminal event publication it revokes and removes the binding. Capability
tokens are never reused. A delayed request from an older transaction therefore
addresses a revoked token and cannot be interpreted as a call in a newer
transaction, even when tool names and SessionId are identical.
After local revocation, cleanup makes a bounded
begin_refresh_mcp(attachment, None) attempt to remove the stale descriptor
from a reusable external session. Failure is a safe diagnostic and cannot
restore or prolong the already revoked local capability.
Required MCP methods:
initialize;notifications/initialized;ping;tools/list; andtools/call.
Pagination is implemented if the selected MCP protocol version requires or permits it; an unbounded list is prohibited.
Revoked, stale, unknown, cross-session, disallowed, schema-invalid, overloaded, and terminating calls return typed MCP/HTTP errors and never reach a handler. There is no inactive persistent endpoint: after revocation the capability route does not exist. The gateway has global, per-binding, body-byte, request-duration, and concurrent-call limits.
The initial loopback transport supports only agent processes that can reach the Monoloop host's loopback namespace. A Channel must declare and validate that reachability. Remote, container-isolated, or VM-isolated agents require a separately secured and qualified transport profile; they are rejected by the loopback profile rather than receiving an unusable URL.
CreationOnly profiles are qualified separately: one newly created session,
one transaction capability, and no later reuse of that attachment. They are not
advertised as supporting request-scoped tool changes on reusable sessions.
The initial direct-LLM Channel uses:
POST <configured base URL>/v1/chat/completions
Content-Type: application/json
Authorization: configured by credential resolver
stream: true
The generic HTTP Connector uses reqwest with Rustls and streaming enabled. It
owns DNS, TLS, proxy policy, headers, authentication, status, response-byte
streaming, cancellation, and transport timeouts. A host-injected
CredentialResolver resolves configured credential references. Invocation
config cannot supply an endpoint, header, token, or credential reference.
The outbound encoder supports:
- ordered
messages; model;temperature;reasoning_effortonly when declared by the Channel;max_tokensormax_completion_tokensaccording to a versioned capability;stop;- declared response format;
toolsgenerated fromResolvedToolSet; andtool_choiceonly through a typed declared option.
The Interpreter accepts SSE frames:
data: <JSON>
data: [DONE]
It:
- incrementally frames across arbitrary byte boundaries;
- bounds line, event, JSON, text, choice, and tool-assembly storage;
- rejects malformed UTF-8/JSON/SSE with typed interpretation failure;
- selects exactly one configured choice index;
- assembles text into existing complete canonical text units;
- assembles tool calls by choice index, tool-call index, ID, name, and arguments;
- emits
ToolRequestReadyonly after complete valid JSON arguments; - maps declared finish reasons without treating EOF alone as success; and
- requires
[DONE]or another explicitly qualified terminal condition.
Every provider tool call is assigned:
CanonicalToolActionKey
ExchangeId
provider tool_call_id
ToolActionId
Monoloop-generated internal identity
Provider call IDs need be unique only within one exchange. Deduplication and
completion correlation use CanonicalToolActionKey; continuation encoding uses
the preserved provider call ID. A repeated provider ID in a later exchange
cannot collide with an earlier action.
Tool calls are grouped by one provider exchange. The runtime may execute calls
from that group concurrently within configured limits, but it does not send a
continuation until the exchange has reached its qualified tool_calls finish
condition and every call in the group is terminal. Continuation tool-result
messages preserve the model-declared call order regardless of execution
completion order.
For a tool continuation, the encoder appends the assistant tool-call message and
one role: "tool" message per completed call, preserving call IDs. A new HTTP
exchange is opened for each continuation; all exchanges retain the same
TransactionId and SessionKey but receive new exchange, connection, and
interpretation identities.
Continuation context retains only the canonical input plus assistant tool-call
and tool-result messages required to encode the next request. Its encoded size,
total transaction request bytes, total transaction response bytes, exchange
count, and continuation count are independently bounded. Exceeding any bound
selects LimitExceeded; history is never silently truncated.
Non-streaming responses, OpenAI Responses events, provider-specific NDJSON, and silent field renaming are not included in this dialect. A Channel requiring them must select another qualified dialect.
At least two provider profiles must pass the same deterministic protocol suite with different endpoint/default configuration and no provider-name branch.
RuntimeConfig::validate checks every bound before starting the runtime.
pub struct TransactionLimits {
pub max_active_transactions: usize,
pub max_active_per_channel: usize,
/// Supervisor control `mpsc` item capacity (D-015 remap of this field).
pub max_actor_commands: usize,
/// Reserved; not enforced (D-057 — `ControlCommand` is a closed enum).
pub max_actor_command_bytes: usize,
/// Runtime ceiling over caller `DeliveryLimits.max_event_items` (D-055).
pub max_event_queue: usize,
/// Runtime ceiling over caller `DeliveryLimits.max_event_bytes` (D-055).
pub max_event_queue_bytes: usize,
pub max_input_bytes: usize,
pub max_messages: usize,
pub max_content_parts: usize,
pub max_tools_per_transaction: usize,
/// Tool input-schema JSON byte ceiling at `StartedRuntime::start` (D-056).
pub max_tool_schema_bytes: usize,
pub max_tool_payload_bytes: usize,
pub max_tool_output_bytes: usize,
pub max_concurrent_tools_per_transaction: usize,
pub max_queued_tools_per_transaction: usize,
pub max_continuations: usize,
pub max_provider_exchanges: usize,
pub max_continuation_context_bytes: usize,
pub max_total_provider_input_bytes: usize,
pub max_total_provider_output_bytes: usize,
pub max_diagnostic_count: usize,
pub max_diagnostic_bytes: usize,
pub transaction_deadline: Duration,
pub cleanup_deadline: Duration,
pub terminal_event_delivery_deadline: Duration,
pub callback_deadline: Duration,
}Runtime config also contains bounded Connector, Interpreter, Loop, MCP, global tool, per-tool, callback, and shutdown limits. Zero capacity, overflow-prone relationships, callback capacity below active-transaction capacity, and deadlines beyond configured maxima are configuration errors.
Queues carrying variable-sized values acquire both an item permit and a byte permit before enqueue and release both on dequeue/drop. Tokio channel item capacity alone does not satisfy the byte bounds.
shutdown atomically changes runtime state from Accepting to Draining.
Subsequent admission returns RuntimeShuttingDown.
The runtime:
- snapshots active control handles without awaiting under the registry lock;
- requests
RuntimeShutdownterminalization for all actors; - waits for actors to claim their
FinalizationGuardand schedule callbacks; - at the actor shutdown deadline, aborts and joins remaining actors;
- claims each aborted actor's still-unclaimed
FinalizationGuard, attempts its final event asRuntimeShutdown, removes its registry entries, and invokes its callback; - verifies that every admitted transaction has one claimed finalization guard;
- closes the MCP listener and revokes all remaining capabilities;
- prevents new callback reservations, then waits for already invoked callback futures only until their individual callback deadlines;
- clears bounded services and registries; and
- enters
Stopped.
The supplied shutdown deadline bounds the whole shutdown operation. Runtime
configuration reserves a nonzero finalization portion of that deadline. Every
callback's call method is invoked exactly once before shutdown completes; its
returned future receives the smaller of its callback deadline and the remaining
global shutdown time, then is aborted if still pending. Shutdown returns a
ShutdownDisposition containing counts for normally finalized, supervisor
finalized, callback-failed, callback-aborted, and invariant-failed
transactions.
It writes no recovery state. Blocking work is prohibited inside callback invocation, tool handlers, and async adapters unless isolated behind a separately bounded, owned blocking service.
Component errors retain their component-specific detail internally and map once:
open/session attach failure -> ChannelOpenFailed
outbound encoder failure -> EncodingFailed
Connector terminal failure -> ConnectorFailed
Interpreter terminal failure -> InterpretationFailed
Loop/dispatcher/tool failure -> ToolExchangeFailed
required event sink failure -> EventDeliveryFailed
deadline -> DeadlineExceeded
configured bound -> LimitExceeded
graceful runtime shutdown -> RuntimeShutdown
impossible state/stale in-scope ID -> InvariantFailed
Cancellation caused by caller intent is Cancelled or Terminated, not the
component error produced while cancellation tears a child down.
Declared tool-domain failure and invalid tool arguments are canonical tool
outcomes, not ToolExchangeFailed.
Workspace dependencies added through Cargo and pinned by Cargo.lock:
reqwestwith Rustls, JSON, and streaming features for generic HTTP;rmcpwith server and Streamable HTTP support for MCP;axum/toweronly as required by the selectedrmcptransport integration;jsonschemafor canonical tool-input validation;secrecyfor resolved credential values; and- an OS-backed CSPRNG (
rand/getrandom) for capability tokens.
The implementation records the selected versions and verifies their MSRV, license, enabled features, and duplicate dependency impact. Default TLS or native-system features not used by the design are disabled. No protocol or schema validator is replaced with ad hoc parsing merely to avoid a dependency.
src/id.rs add TransactionId, ExchangeId, SessionId/Key, ChannelId, ToolId/Name
src/input.rs CanonicalInput and validation
src/config.rs invocation/effective config and extensions
src/transaction.rs request, receipt, events, terminal, sinks, runtime trait
src/channel.rs Channel data contracts and capabilities
src/tool.rs canonical tool specification/call/result/lifecycle
src/encoder.rs outbound encoder contracts
src/limits.rs transaction/tool/MCP/callback limits
src/dialect.rs OpenAiChatCompletions family and descriptor
src/lib.rs public exports
src/http.rs generic streaming HTTP Connector
src/credential.rs host-injected CredentialResolver
src/factory.rs ConnectorFactory, ConnectorInstance, instance identity
src/session.rs SessionAdapter, attachment, and opaque owned route
src/open.rs accept and validate optional SessionAttachment
tests/http.rs status, streaming, cancellation, limits, auth redaction
src/openai_chat.rs bounded SSE and Chat Completions assembly
src/engine.rs dialect dispatch
src/factory.rs profile construction
tests/openai_chat.rs fragmentation, text, tools, malformed/oversized streams
src/transaction/mod.rs
src/transaction/runtime.rs DefaultTransactionRuntime
src/transaction/admission.rs validation, permits, registry installation
src/transaction/actor.rs serialized transaction owner
src/transaction/command.rs internal command vocabulary
src/transaction/state.rs explicit transition function
src/transaction/events.rs sequencer and bounded delivery task
src/transaction/terminal.rs terminal selection and cleanup
src/transaction/finalization.rs shared exactly-once shutdown/actor guard
src/transaction/callback.rs bounded callback reservations/executor
src/transaction/registry.rs active transaction/session registry
src/transaction/exchange.rs per-exchange driver and I/O pump
src/transaction/distributor.rs actor/inner-Loop canonical fan-out
src/channel/binding.rs live ChannelBinding
src/channel/registry.rs immutable Channel registry
src/encoder/openai_chat.rs initial and continuation encoding
src/tools/host_registry.rs immutable linked registry
src/tools/resolved.rs request-scoped ResolvedToolSet
src/tools/dispatcher.rs one validated execution path
src/tools/execution.rs handles, cancellation, completion
src/tools/loop_ports.rs ToolRegistry/ToolRuntime adapters
src/mcp/gateway.rs rmcp server and bounded routing
src/mcp/binding.rs immutable transaction capability binding
src/mcp/error.rs typed fail-closed mapping
The current runtime.rs, registry.rs, tools.rs, and subscription.rs
remain the inner LoopRuntime; refactor them under src/loop_runtime/ only when
the move is mechanical and covered by existing tests.
Each existing profile crate adds channel_binding.rs and a
ConnectorFactory producing a matched Connector/SessionAdapter instance.
Prompt-in-open shortcuts are removed after the binding is qualified. Every
profile declares:
- session create/load behavior;
- whether MCP can be installed/refreshed;
- authoritative completion rule;
- tool execution mode;
- supported continuation policy; and
- static capability/limit defaults.
A profile that cannot provide request-scoped MCP tools rejects a non-empty tool set. It is not labelled tool-compatible.
src/transaction/fake_channel.rs
src/transaction/fake_session.rs
src/transaction/callback_recorder.rs
src/transaction/event_recorder.rs
src/transaction/fake_tool.rs
src/transaction/mcp_client.rs
src/transaction/race.rs
These utilities expose barriers and deterministic faults; production crates do not depend on them.
Deliver contracts, admission, actor, registry, event delivery, callback, terminal protocol, shutdown, and fake Channel composition.
Gate:
- bounded submit returns before fake I/O is released;
- generated/supplied session identity;
- duplicate active SessionKey rejected;
- identical provider session strings on different Channels remain isolated;
- terminate by transaction ID before external session creation;
- terminate by session after establishment;
- cancellation in every phase;
- exactly one terminal event and callback attempt;
- graceful shutdown invokes every admitted callback, including for an actor requiring supervisor finalization;
- no event after terminal;
- no registry/task/permit leak.
Deliver host registry, resolved set, real asynchronous execution handles, dispatcher, Loop adapters, lifecycle events, cancellation, and a complete continuation path using a deterministic fake dialect.
Gate:
- unknown/duplicate tools reject admission;
- empty set discovers and executes nothing;
- different simultaneous transactions expose different sets;
- schema, payload, queue, transaction, global, and per-tool limits;
- output-contract and tool-output byte validation;
- domain failure continuation versus runtime failure termination;
- rejection of unbounded/non-cancellable handlers;
- completion/cancel races;
- direct fake-model tool/result/continuation cycle;
- no
Available -> DispatchRejectedproduction placeholder.
Deliver loopback gateway, capability bindings, external session installation, and parity tests.
Gate:
- protocol initialization, list, call, error, cancellation, and shutdown;
- inactive/stale/unknown/cross-session requests fail closed;
- a delayed request using transaction A's capability cannot enter transaction B;
- capability tokens never appear in logs/diagnostics;
- MCP and local projections have equivalent names/descriptions/schemas;
- both routes reach the identical registered handler;
- rebinding the same external session changes availability atomically.
Deliver HTTP Connector, Chat Completions encoder/Interpreter, and full direct model/tool/model flow.
Gate:
- text streaming under every byte fragmentation;
- malformed, truncated, oversized, delayed, disconnected, and non-success HTTP responses;
- multi-fragment and multiple tool calls;
- repeated provider tool-call IDs in different exchanges remain distinct;
- a fresh connection and interpretation are used for every continuation;
- continuation-context and total provider byte limits;
- configured continuation ceiling;
- cancellation during DNS/connect/body/tool/continuation;
- two provider profiles use the same implementation without provider branches;
- secrets and raw bodies absent from diagnostics.
Deliver Channel bindings and session adapters for Grok, Cursor, Antigravity, Codex, Z.ai, and Claude Code.
Gate:
- create and reuse behavior per profile;
- honest
None/CreationOnly/RefreshableMCP support matrix; - many concurrent different sessions;
- same-SessionKey rejection;
- no prompt shortcut bypassing canonical encoding;
- all profile and workspace conformance tests.
Each slice adds direct assertions for normal, boundary, malformed, timeout, cancellation, termination, and concurrency paths. Required cross-cutting tests:
- completion vs cancellation;
- completion vs deadline;
- termination vs Connector open;
- termination vs event backpressure;
- termination vs tool start/completion;
- external session claim collision;
- stale child completion after session reuse;
- stale MCP call after transaction replacement;
- callback failure/panic/deadline;
- shutdown with active transactions and callbacks;
- maximum configured concurrency plus one;
- sequence continuity under concurrent producers;
- zero live actors, child tasks, routes, registry entries, and held permits after every terminal kind;
- identical raw session IDs on different Channels;
- delayed old MCP call after capability rotation;
- forced-shutdown supervisor finalization and one callback invocation;
- terminal-event delivery after the transaction deadline;
- output-contract violation versus declared tool-domain failure;
- repeated provider tool-call IDs across exchanges; and
- continuation-context and aggregate provider-byte exhaustion.
Use paused Tokio time, barriers, scripted transports, and deterministic fake handlers. Do not use sleeps for ordering. Live providers are qualification evidence only.
For each delivered slice the gate is:
cargo fmt --all -- --check
cargo test --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo doc --workspace --no-deps
Architecture tests must also reject product dependencies on monoloop-testkit,
concrete profile branches in transaction code, blocking I/O in async modules,
and production todo!/unimplemented!.
A slice is done only when:
- every production branch in that slice has real behavior;
- its public contracts and limits are documented and enforced;
- all spawned work has one owner and bounded teardown;
- every terminal race has one truthful outcome;
- all required tests pass without conditional skipping;
- strict formatting, Clippy, tests, and docs pass;
- no P0, P1, or P2 defect remains in the delivered scope; and
- the requirements checkboxes are updated only for behavior actually delivered.
Examples, mocked demonstrations, type skeletons, empty implementations, and happy-path-only tests do not satisfy this definition.