Skip to main content

conversation_api/execution/wire/
mod.rs

1//! Versioned transport contract for Agent commands, events, and App Facade calls.
2//!
3//! This crate defines envelopes but no WebSocket client, authentication, reconnect policy, or
4//! server. Transport implementations belong to their deployment repository.
5
6/// Golden enqueue envelope used by deployment and Runtime conformance tests.
7pub const ENQUEUE_FIXTURE: &str = include_str!("../../../fixtures/execution/enqueue.v1.json");
8
9use crate::execution::{
10    AgentCommand, AgentObservation, ContextGeneration, ConversationSurface, DurableEvent, EventId,
11    FacadeRequest, FacadeResult, InvocationContext, ModelMode, PreparedAction, Revision, RunId,
12    Scope, ThreadId, UseCase,
13};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// Current protocol major version.
18pub const CURRENT_VERSION: u16 = 1;
19
20/// Ephemeral cloud execution identity assigned by Lion.
21///
22/// Both values belong to the transport boundary and never enter the provider-neutral Runtime.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct DispatchBinding {
25    /// Unique identity for one execution attempt.
26    pub dispatch_id: String,
27    /// Authenticated Agent WebSocket session selected for the attempt.
28    pub agent_session_id: String,
29}
30
31/// Immutable object-store reference to one Runtime checkpoint generation.
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct CheckpointReference {
34    /// Object key inside the configured checkpoint bucket.
35    pub object_key: String,
36    /// Lowercase hexadecimal SHA-256 of the checkpoint bytes.
37    pub sha256: String,
38    /// Runtime checkpoint format understood by `agent-rust`.
39    pub format_version: u32,
40}
41
42/// Metadata shared by every wire message.
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44pub struct Metadata {
45    /// Protocol major version.
46    pub version: u16,
47    /// Globally unique message identifier.
48    pub message_id: String,
49    /// Identifier correlating a request and its response or emitted events.
50    pub correlation_id: String,
51    /// Optional identifier of the message that caused this one.
52    pub causation_id: Option<String>,
53    /// Sender timestamp in Unix milliseconds.
54    pub sent_at_unix_ms: u64,
55    /// Execution-attempt and Agent-session binding.
56    pub dispatch: DispatchBinding,
57}
58
59/// Request to execute an App Facade call over a transport.
60#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "phase", rename_all = "snake_case")]
62pub enum AppFacadeRequest {
63    /// Executes a direct App Facade request.
64    Invoke {
65        /// Authenticated invocation context.
66        context: InvocationContext,
67        /// Canonical Agent-selected request.
68        request: FacadeRequest,
69    },
70    /// Prepares a mutating operation.
71    Prepare {
72        /// Authenticated invocation context.
73        context: InvocationContext,
74        /// Canonical Agent-selected request.
75        request: FacadeRequest,
76    },
77    /// Commits a previously prepared operation.
78    Commit {
79        /// Authenticated invocation context.
80        context: InvocationContext,
81        /// Prepared operation and idempotency metadata.
82        action: PreparedAction,
83    },
84    /// Rejects a previously prepared operation.
85    Reject {
86        /// Authenticated invocation context.
87        context: InvocationContext,
88        /// Prepared operation and idempotency metadata.
89        action: PreparedAction,
90    },
91}
92
93/// Immediate response to command admission for the selected execution attempt.
94#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
95#[serde(tag = "type", rename_all = "snake_case")]
96pub enum CommandResponse {
97    /// Command was admitted; final state is delivered through events.
98    Accepted {
99        /// Authenticated context of the admitted command.
100        context: InvocationContext,
101        /// Stable admission disposition.
102        disposition: AdmissionDisposition,
103        /// Queue revision when the command mutated the admission queue.
104        queue_revision: Option<Revision>,
105    },
106    /// Command was rejected before admission.
107    Rejected {
108        /// Authenticated context of the rejected command.
109        context: InvocationContext,
110        /// Stable machine-readable error code.
111        code: String,
112        /// Safe diagnostic message.
113        message: String,
114    },
115}
116
117/// Stable wire representation of command admission.
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum AdmissionDisposition {
121    /// A Lion-dispatched request entered the Agent process's execution queue.
122    Queued,
123    /// Interaction decisions are durable and ready to resume.
124    InteractionReady,
125    /// Cancellation has been signaled to the live Agent process.
126    CancelRequested,
127}
128
129/// Routed best-effort observation sent outside the durable event outbox.
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131pub struct ObservationMessage {
132    /// Authenticated invocation and routing context.
133    pub context: InvocationContext,
134    /// Request-scoped progress payload.
135    pub observation: AgentObservation,
136}
137
138/// Terminal transport failure for an admitted execution that could not produce a checkpoint event.
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140pub struct ExecutionFailure {
141    pub context: InvocationContext,
142    pub code: String,
143    pub message: String,
144}
145
146/// Acknowledges one durable event after the receiver has applied or deduplicated it.
147#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
148pub struct EventAck {
149    pub event_id: EventId,
150    /// Checkpoint reference Lion actually selected while applying (or deduplicating) the event.
151    pub checkpoint: CheckpointReference,
152}
153
154/// Lion-owned archive GC request for all Runtime checkpoints under one private thread.
155#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
156pub struct CheckpointPurgeRequest {
157    pub purge_id: String,
158    pub conversation_key: String,
159    pub scope: Scope,
160    pub surface_id: ConversationSurface,
161    pub thread_id: ThreadId,
162}
163
164/// Result of one idempotent checkpoint-prefix purge.
165#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "status", rename_all = "snake_case")]
167pub enum CheckpointPurgeAck {
168    Deleted {
169        purge_id: String,
170        conversation_key: String,
171        thread_id: ThreadId,
172        deleted_objects: u32,
173    },
174    Failed {
175        purge_id: String,
176        conversation_key: String,
177        thread_id: ThreadId,
178        code: String,
179        message: String,
180    },
181}
182
183/// Model facts captured by the deployment with the desired route parameters.
184/// This snapshot belongs to admission, not to the provider-neutral LLM request.
185#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct LlmModelSnapshot {
188    pub capabilities: llm_api::ModelCapabilities,
189    pub generation_support: LlmGenerationSupport,
190}
191
192/// Confirmed parameter support from catalog metadata.generationSupport.
193/// Missing information uses conservative adapter defaults; it is never inferred from preferences.
194#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct LlmGenerationSupport {
197    pub temperature: Option<bool>,
198    pub max_tokens: Option<bool>,
199    pub reasoning_efforts: Option<Vec<String>>,
200    pub temperature_with_reasoning: Option<bool>,
201    pub temperature_max: Option<f64>,
202    pub max_output_tokens: Option<u32>,
203}
204
205impl LlmGenerationSupport {
206    pub fn validate(&self) -> Result<(), &'static str> {
207        if self
208            .temperature_max
209            .is_some_and(|v| !v.is_finite() || v < 0.0)
210            || self.max_output_tokens == Some(0)
211        {
212            return Err("invalid generation support limits");
213        }
214        for effort in self.reasoning_efforts.iter().flatten() {
215            llm_api::GenerationParameters {
216                temperature: None,
217                reasoning_effort: Some(effort.clone()),
218            }
219            .validate()?;
220        }
221        Ok(())
222    }
223}
224
225/// One concrete cloud model route frozen by the deployment before command admission.
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
227pub struct LlmRoute {
228    pub backend: String,
229    pub profile: String,
230    pub model: String,
231    pub provider_preferences: Vec<String>,
232    pub temperature: Option<f64>,
233    pub max_output_tokens: Option<u32>,
234    pub cache_control: Option<bool>,
235    pub reasoning_effort: Option<String>,
236    /// Total context window of the exact frozen model route.
237    pub context_window_tokens: u32,
238    /// Required immutable facts; old plans must not silently resolve against a new catalog.
239    pub model_snapshot: LlmModelSnapshot,
240}
241
242/// One logical selector and its concrete deployment route.
243#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
244pub struct LlmRouteBinding {
245    pub use_case: UseCase,
246    pub model_mode: ModelMode,
247    pub revision: Option<String>,
248    pub route: LlmRoute,
249}
250
251/// Complete deployment-resolved LLM route set bound immutably to one admitted run.
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253pub struct LlmExecutionPlan {
254    pub routes: Vec<LlmRouteBinding>,
255}
256
257impl LlmExecutionPlan {
258    /// Selects the exact frozen route for one provider-neutral Runtime request.
259    #[must_use]
260    pub fn route_for(
261        &self,
262        use_case: &UseCase,
263        model_mode: &ModelMode,
264    ) -> Option<&LlmRouteBinding> {
265        self.routes
266            .iter()
267            .find(|item| item.use_case == *use_case && item.model_mode == *model_mode)
268    }
269}
270
271/// Transport admission request. Environment bindings are consumed by the composition root and do
272/// not enter the provider-neutral Runtime command.
273#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
274pub struct CommandRequest {
275    pub command: AgentCommand,
276    /// Effective prompt-context generation selected by Lion for this dispatch.
277    pub context_generation: Option<ContextGeneration>,
278    pub llm_plan: Option<LlmExecutionPlan>,
279    /// Stable Runtime checkpoint selected by Lion, absent for a new thread.
280    pub base_checkpoint: Option<CheckpointReference>,
281    /// Attempt already terminalized by Lion and settled silently before new work starts.
282    pub abandoned_run_id: Option<RunId>,
283}
284
285/// Checkpoint-coupled Runtime event. Lion commits the generation before projecting the event.
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287pub struct EventMessage {
288    pub event: DurableEvent,
289    pub checkpoint: CheckpointReference,
290}
291
292/// Result of an App Facade request transported back to Runtime.
293#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
294#[serde(tag = "type", rename_all = "snake_case")]
295pub enum AppFacadeResponse {
296    /// Read-only or committed operation result.
297    Result {
298        /// Structured App Facade result.
299        result: FacadeResult,
300    },
301    /// Prepared operation awaiting a commit or rejection.
302    Prepared {
303        /// Prepared action.
304        action: PreparedAction,
305    },
306    /// Rejection completed successfully.
307    Rejected,
308    /// Stable error safe to expose across the transport.
309    Failed {
310        /// Machine-readable category.
311        code: String,
312        /// Safe diagnostic message.
313        message: String,
314        /// Optional retry delay.
315        retry_after_ms: Option<u64>,
316    },
317}
318
319/// Payload families carried by the versioned envelope.
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[serde(tag = "family", content = "payload", rename_all = "snake_case")]
322pub enum Payload {
323    /// Command entering Runtime.
324    Command(Box<CommandRequest>),
325    /// Immediate command-admission response.
326    CommandResponse(CommandResponse),
327    /// Event leaving Runtime.
328    Event(EventMessage),
329    /// Durable receiver acknowledgement used to clear the Runtime outbox.
330    EventAck(EventAck),
331    /// Lion asks the cloud composition to remove an archived thread's Runtime checkpoints.
332    CheckpointPurge(CheckpointPurgeRequest),
333    /// Agent-cloud reports the idempotent S3 purge result.
334    CheckpointPurgeAck(CheckpointPurgeAck),
335    /// Non-durable progress leaving Runtime.
336    Observation(ObservationMessage),
337    /// One dispatch failed outside the checkpointed Runtime state machine.
338    ExecutionFailure(ExecutionFailure),
339    /// Runtime request to the App Facade.
340    AppFacadeRequest(AppFacadeRequest),
341    /// App Facade response returned to Runtime.
342    AppFacadeResponse(AppFacadeResponse),
343}
344
345/// Complete transport message.
346#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
347pub struct Envelope {
348    /// Correlation and version metadata.
349    pub metadata: Metadata,
350    /// Typed payload.
351    pub payload: Payload,
352}
353
354impl Envelope {
355    /// Strictly decodes a JSON envelope and rejects fields unknown to the Rust DTO graph.
356    ///
357    /// Transport adapters should use this entry point rather than calling `serde_json` directly.
358    ///
359    /// # Errors
360    ///
361    /// Returns `ProtocolError` for malformed JSON, unknown fields, or failed envelope validation.
362    pub fn decode_json(input: &str) -> Result<Self, ProtocolError> {
363        let input_value: serde_json::Value =
364            serde_json::from_str(input).map_err(|error| ProtocolError::InvalidJson {
365                message: error.to_string(),
366            })?;
367        let mut deserializer = serde_json::Deserializer::from_str(input);
368        let mut unknown = Vec::new();
369        let envelope: Self = serde_ignored::deserialize(&mut deserializer, |path| {
370            unknown.push(path.to_string());
371        })
372        .map_err(|error| ProtocolError::InvalidJson {
373            message: error.to_string(),
374        })?;
375        deserializer
376            .end()
377            .map_err(|error| ProtocolError::InvalidJson {
378                message: error.to_string(),
379            })?;
380        if let Some(path) = unknown.into_iter().next() {
381            return Err(ProtocolError::UnknownField { path });
382        }
383        let canonical =
384            serde_json::to_value(&envelope).map_err(|error| ProtocolError::InvalidJson {
385                message: error.to_string(),
386            })?;
387        if let Some(path) = first_extra_field(&input_value, &canonical, "$") {
388            return Err(ProtocolError::UnknownField { path });
389        }
390        envelope.validate()?;
391        Ok(envelope)
392    }
393
394    /// Validates transport-level invariants before dispatch.
395    ///
396    /// # Errors
397    ///
398    /// Returns `ProtocolError` when the major version is unsupported or a required identifier is
399    /// blank.
400    pub fn validate(&self) -> Result<(), ProtocolError> {
401        if self.metadata.version != CURRENT_VERSION {
402            return Err(ProtocolError::UnsupportedVersion {
403                actual: self.metadata.version,
404            });
405        }
406        if self.metadata.message_id.trim().is_empty() {
407            return Err(ProtocolError::MissingIdentifier("message_id"));
408        }
409        if self.metadata.correlation_id.trim().is_empty() {
410            return Err(ProtocolError::MissingIdentifier("correlation_id"));
411        }
412        validate_dispatch(&self.metadata.dispatch)?;
413        if let Some(context) = self.context() {
414            validate_context(context)?;
415        }
416        if let Payload::Command(request) = &self.payload {
417            validate_command_binding(request)?;
418        }
419        if let Payload::CheckpointPurge(request) = &self.payload {
420            validate_purge_request(request)?;
421        }
422        if let Payload::CheckpointPurgeAck(ack) = &self.payload {
423            validate_purge_ack(ack)?;
424        }
425        if let Payload::EventAck(ack) = &self.payload {
426            validate_checkpoint_reference(&ack.checkpoint)?;
427        }
428        match &self.payload {
429            Payload::Command(request) => {
430                if let Some(reference) = &request.base_checkpoint {
431                    validate_checkpoint_reference(reference)?;
432                }
433                if matches!(&request.command, AgentCommand::Cancel { .. })
434                    && (request.base_checkpoint.is_some() || request.abandoned_run_id.is_some())
435                {
436                    return Err(ProtocolError::InvalidBinding(
437                        "cancel must target live execution without checkpoint recovery metadata"
438                            .to_owned(),
439                    ));
440                }
441                if request.abandoned_run_id.is_some() && request.base_checkpoint.is_none() {
442                    return Err(ProtocolError::InvalidBinding(
443                        "an abandoned run requires a base checkpoint".to_owned(),
444                    ));
445                }
446            }
447            Payload::Event(message) => validate_checkpoint_reference(&message.checkpoint)?,
448            _ => {}
449        }
450        Ok(())
451    }
452
453    fn context(&self) -> Option<&InvocationContext> {
454        match &self.payload {
455            Payload::Command(request) => match &request.command {
456                AgentCommand::Enqueue { context, .. }
457                | AgentCommand::SubmitInteraction { context, .. }
458                | AgentCommand::Cancel { context } => Some(context),
459            },
460            Payload::Event(message) => Some(&message.event.context),
461            Payload::Observation(message) => Some(&message.context),
462            Payload::ExecutionFailure(message) => Some(&message.context),
463            Payload::AppFacadeRequest(request) => match request {
464                AppFacadeRequest::Invoke { context, .. }
465                | AppFacadeRequest::Prepare { context, .. }
466                | AppFacadeRequest::Commit { context, .. }
467                | AppFacadeRequest::Reject { context, .. } => Some(context),
468            },
469            Payload::CommandResponse(response) => match response {
470                CommandResponse::Accepted { context, .. }
471                | CommandResponse::Rejected { context, .. } => Some(context),
472            },
473            Payload::EventAck(_)
474            | Payload::CheckpointPurge(_)
475            | Payload::CheckpointPurgeAck(_)
476            | Payload::AppFacadeResponse(_) => None,
477        }
478    }
479}
480
481fn validate_purge_request(request: &CheckpointPurgeRequest) -> Result<(), ProtocolError> {
482    if request.purge_id.trim().is_empty() {
483        return Err(ProtocolError::MissingIdentifier("purge_id"));
484    }
485    if request.conversation_key.trim().is_empty() {
486        return Err(ProtocolError::MissingIdentifier("conversation_key"));
487    }
488    if request.scope.scope_id.as_str().trim().is_empty() {
489        return Err(ProtocolError::MissingIdentifier("scope_id"));
490    }
491    if request.thread_id.as_str().trim().is_empty() {
492        return Err(ProtocolError::MissingIdentifier("thread_id"));
493    }
494    Ok(())
495}
496
497fn validate_purge_ack(ack: &CheckpointPurgeAck) -> Result<(), ProtocolError> {
498    let (purge_id, conversation_key, thread_id) = match ack {
499        CheckpointPurgeAck::Deleted {
500            purge_id,
501            conversation_key,
502            thread_id,
503            ..
504        }
505        | CheckpointPurgeAck::Failed {
506            purge_id,
507            conversation_key,
508            thread_id,
509            ..
510        } => (purge_id, conversation_key, thread_id),
511    };
512    if purge_id.trim().is_empty() {
513        return Err(ProtocolError::MissingIdentifier("purge_id"));
514    }
515    if conversation_key.trim().is_empty() {
516        return Err(ProtocolError::MissingIdentifier("conversation_key"));
517    }
518    if thread_id.as_str().trim().is_empty() {
519        return Err(ProtocolError::MissingIdentifier("thread_id"));
520    }
521    if let CheckpointPurgeAck::Failed { code, .. } = ack
522        && code.trim().is_empty()
523    {
524        return Err(ProtocolError::MissingIdentifier("code"));
525    }
526    Ok(())
527}
528
529fn validate_dispatch(dispatch: &DispatchBinding) -> Result<(), ProtocolError> {
530    if dispatch.dispatch_id.trim().is_empty() {
531        return Err(ProtocolError::MissingIdentifier("dispatch_id"));
532    }
533    if dispatch.agent_session_id.trim().is_empty() {
534        return Err(ProtocolError::MissingIdentifier("agent_session_id"));
535    }
536    Ok(())
537}
538
539fn validate_checkpoint_reference(reference: &CheckpointReference) -> Result<(), ProtocolError> {
540    if reference.object_key.trim().is_empty() {
541        return Err(ProtocolError::MissingIdentifier("checkpoint_object_key"));
542    }
543    if reference.format_version == 0
544        || reference.sha256.len() != 64
545        || !reference
546            .sha256
547            .bytes()
548            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
549    {
550        return Err(ProtocolError::InvalidBinding(
551            "checkpoint reference is invalid".to_owned(),
552        ));
553    }
554    Ok(())
555}
556
557fn validate_command_binding(request: &CommandRequest) -> Result<(), ProtocolError> {
558    match (&request.command, &request.context_generation) {
559        (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, Some(value))
560            if value.is_complete() => {}
561        (AgentCommand::Enqueue { .. } | AgentCommand::SubmitInteraction { .. }, _) => {
562            return Err(ProtocolError::InvalidBinding(
563                "enqueue and submit_interaction require a context generation".to_owned(),
564            ));
565        }
566        (AgentCommand::Cancel { .. }, None) => {}
567        (AgentCommand::Cancel { .. }, Some(_)) => {
568            return Err(ProtocolError::InvalidBinding(
569                "cancel must not carry a context generation".to_owned(),
570            ));
571        }
572    }
573    match (&request.command, &request.llm_plan) {
574        (AgentCommand::Enqueue { options, .. }, Some(plan)) => {
575            validate_llm_plan(plan)?;
576            if plan
577                .route_for(&options.use_case, &options.model_mode)
578                .is_none()
579            {
580                return Err(ProtocolError::InvalidBinding(
581                    "LLM plan does not contain the command's primary selector".to_owned(),
582                ));
583            }
584            Ok(())
585        }
586        (AgentCommand::Enqueue { .. }, None) => Err(ProtocolError::InvalidBinding(
587            "enqueue requires a frozen LLM plan".to_owned(),
588        )),
589        (AgentCommand::SubmitInteraction { .. }, Some(plan)) => validate_llm_plan(plan),
590        (AgentCommand::SubmitInteraction { .. }, None) => Err(ProtocolError::InvalidBinding(
591            "submit_interaction requires the run's frozen LLM plan".to_owned(),
592        )),
593        (_, Some(_)) => Err(ProtocolError::InvalidBinding(
594            "only commands that invoke the model may carry an LLM plan".to_owned(),
595        )),
596        (_, None) => Ok(()),
597    }
598}
599
600fn validate_llm_plan(plan: &LlmExecutionPlan) -> Result<(), ProtocolError> {
601    if plan.routes.is_empty() {
602        return Err(ProtocolError::InvalidBinding(
603            "LLM plan must contain at least one route".to_owned(),
604        ));
605    }
606    let mut selectors = std::collections::HashSet::new();
607    for binding in &plan.routes {
608        let route = &binding.route;
609        route
610            .model_snapshot
611            .generation_support
612            .validate()
613            .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
614        llm_api::GenerationParameters {
615            temperature: route.temperature,
616            reasoning_effort: route.reasoning_effort.clone(),
617        }
618        .validate()
619        .map_err(|error| ProtocolError::InvalidBinding(error.into()))?;
620        if binding.use_case.0.trim().is_empty()
621            || binding.model_mode.0.trim().is_empty()
622            || route.backend.trim().is_empty()
623            || route.profile.trim().is_empty()
624            || route.model.trim().is_empty()
625        {
626            return Err(ProtocolError::InvalidBinding(
627                "LLM use case, model mode, backend, profile, and model are required".to_owned(),
628            ));
629        }
630        if !selectors.insert((binding.use_case.0.as_str(), binding.model_mode.0.as_str())) {
631            return Err(ProtocolError::InvalidBinding(
632                "LLM plan contains a duplicate selector".to_owned(),
633            ));
634        }
635        if binding
636            .revision
637            .as_ref()
638            .is_some_and(|value| value.trim().is_empty())
639            || route
640                .provider_preferences
641                .iter()
642                .any(|value| value.trim().is_empty())
643            || route
644                .reasoning_effort
645                .as_ref()
646                .is_some_and(|value| value.trim().is_empty())
647            || route.temperature.is_some_and(|value| !value.is_finite())
648            || route.max_output_tokens == Some(0)
649            || route.context_window_tokens <= 20_000
650        {
651            return Err(ProtocolError::InvalidBinding(
652                "LLM route contains invalid optional settings".to_owned(),
653            ));
654        }
655    }
656    Ok(())
657}
658
659fn first_extra_field(
660    input: &serde_json::Value,
661    canonical: &serde_json::Value,
662    path: &str,
663) -> Option<String> {
664    match (input, canonical) {
665        (serde_json::Value::Object(input), serde_json::Value::Object(canonical)) => {
666            for (key, value) in input {
667                let child_path = format!("{path}.{key}");
668                let Some(expected) = canonical.get(key) else {
669                    return Some(child_path);
670                };
671                if let Some(extra) = first_extra_field(value, expected, &child_path) {
672                    return Some(extra);
673                }
674            }
675            None
676        }
677        (serde_json::Value::Array(input), serde_json::Value::Array(canonical)) => input
678            .iter()
679            .zip(canonical)
680            .enumerate()
681            .find_map(|(index, (value, expected))| {
682                first_extra_field(value, expected, &format!("{path}[{index}]"))
683            }),
684        _ => None,
685    }
686}
687
688fn validate_context(context: &InvocationContext) -> Result<(), ProtocolError> {
689    validate_scope(&context.scope)?;
690    for (name, value) in [
691        ("user_id", context.actor.user_id.as_str()),
692        ("thread_id", context.thread_id.as_str()),
693        ("run_id", context.run_id.as_str()),
694        ("operation_id", context.operation_id.as_str()),
695    ] {
696        if value.trim().is_empty() {
697            return Err(ProtocolError::MissingIdentifier(name));
698        }
699    }
700    if context
701        .actor
702        .client_id
703        .as_ref()
704        .is_some_and(|client| client.as_str().trim().is_empty())
705    {
706        return Err(ProtocolError::MissingIdentifier("client_id"));
707    }
708    Ok(())
709}
710
711fn validate_scope(scope: &Scope) -> Result<(), ProtocolError> {
712    if scope.scope_id.as_str().trim().is_empty() {
713        return Err(ProtocolError::MissingIdentifier("scope_id"));
714    }
715    if scope
716        .tenant_id
717        .as_ref()
718        .is_some_and(|tenant| tenant.as_str().trim().is_empty())
719    {
720        return Err(ProtocolError::MissingIdentifier("tenant_id"));
721    }
722    Ok(())
723}
724
725/// Invalid wire envelope.
726#[derive(Clone, Debug, Error, Eq, PartialEq)]
727pub enum ProtocolError {
728    /// JSON could not be decoded into an envelope.
729    #[error("invalid protocol JSON: {message}")]
730    InvalidJson {
731        /// Safe parser diagnostic.
732        message: String,
733    },
734    /// The payload contained a field outside the normative schema.
735    #[error("unknown protocol field: {path}")]
736    UnknownField {
737        /// Serde path to the unexpected field.
738        path: String,
739    },
740    /// The sender used a protocol major version this crate does not understand.
741    #[error("unsupported protocol version {actual}")]
742    UnsupportedVersion {
743        /// Received major version.
744        actual: u16,
745    },
746    /// A required identifier was blank.
747    #[error("missing required identifier: {0}")]
748    MissingIdentifier(&'static str),
749    /// Environment binding is absent or disagrees with its Runtime command.
750    #[error("invalid command binding: {0}")]
751    InvalidBinding(String),
752}
753
754#[cfg(test)]
755mod tests {
756    use crate::execution::{
757        AccessMode, Actor, AgentEvent, ClientId, ContentPart, EventId, Message, MessageRole,
758        ModelMode, OperationId, RunId, RunOptions, Scope, ScopeId, TenantId, ThreadId, UseCase,
759        UserId,
760    };
761
762    use super::*;
763
764    #[test]
765    fn rejects_unknown_protocol_version() {
766        let envelope = Envelope {
767            metadata: Metadata {
768                version: CURRENT_VERSION + 1,
769                message_id: "message-1".to_owned(),
770                correlation_id: "correlation-1".to_owned(),
771                causation_id: None,
772                sent_at_unix_ms: 0,
773                dispatch: dispatch(),
774            },
775            payload: Payload::Event(EventMessage {
776                event: DurableEvent {
777                    id: EventId::from("event-1"),
778                    sequence: 1,
779                    context: InvocationContext {
780                        scope: Scope {
781                            tenant_id: None,
782                            scope_id: ScopeId::from("scope-1"),
783                        },
784                        actor: Actor {
785                            user_id: UserId::from("user-1"),
786                            client_id: Some(ClientId::from("client-1")),
787                        },
788                        surface_id: ConversationSurface::client_personal("user-1").unwrap(),
789                        thread_id: ThreadId::from("thread-1"),
790                        run_id: RunId::from("run-1"),
791                        operation_id: OperationId::from("operation-1"),
792                        deadline_unix_ms: None,
793                        traceparent: None,
794                    },
795                    event: AgentEvent::Started,
796                },
797                checkpoint: checkpoint(),
798            }),
799        };
800
801        assert_eq!(
802            envelope.validate(),
803            Err(ProtocolError::UnsupportedVersion {
804                actual: CURRENT_VERSION + 1
805            })
806        );
807    }
808
809    #[test]
810    fn command_fixture_round_trips_without_shape_drift() {
811        let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
812        let envelope = Envelope::decode_json(fixture).expect("valid golden fixture");
813
814        let expected = Envelope {
815            metadata: Metadata {
816                version: CURRENT_VERSION,
817                message_id: "message-1".to_owned(),
818                correlation_id: "correlation-1".to_owned(),
819                causation_id: None,
820                sent_at_unix_ms: 1_700_000_000_000,
821                dispatch: dispatch(),
822            },
823            payload: Payload::Command(Box::new(CommandRequest {
824                command: AgentCommand::Enqueue {
825                    context: InvocationContext {
826                        scope: Scope {
827                            tenant_id: Some(TenantId::from("tenant-1")),
828                            scope_id: ScopeId::from("scope-1"),
829                        },
830                        actor: Actor {
831                            user_id: UserId::from("user-1"),
832                            client_id: Some(ClientId::from("client-1")),
833                        },
834                        surface_id: ConversationSurface::client_personal("user-1").unwrap(),
835                        thread_id: ThreadId::from("thread-1"),
836                        run_id: RunId::from("run-1"),
837                        operation_id: OperationId::from("operation-1"),
838                        deadline_unix_ms: None,
839                        traceparent: None,
840                    },
841                    message: Message {
842                        continuation: None,
843                        role: MessageRole::User,
844                        content: vec![ContentPart::Text {
845                            text: "hello".to_owned(),
846                        }],
847                    },
848                    options: RunOptions {
849                        use_case: UseCase("chat".to_owned()),
850                        model_mode: ModelMode("auto".to_owned()),
851                        access_mode: AccessMode::Interactive,
852                        allow_tools: true,
853                        max_steps: 8,
854                        credit_budget: None,
855                        debug: false,
856                    },
857                },
858                context_generation: Some(ContextGeneration {
859                    user_scope: "scope-1".to_owned(),
860                    identity: "identity-1".to_owned(),
861                    memory: "memory-1".to_owned(),
862                    integration_guide: "guide-1".to_owned(),
863                    installed_integrations: "installed-1".to_owned(),
864                    scope_integrations: "scope-integrations-1".to_owned(),
865                }),
866                llm_plan: Some(LlmExecutionPlan {
867                    routes: [
868                        "chat",
869                        "agent_info_merge",
870                        "automation_judge",
871                        "automation_diagnose",
872                        "app_guide",
873                        "workflow.action_operate",
874                        "workflow.automation_generate",
875                        "workflow.description_generate",
876                    ]
877                    .into_iter()
878                    .map(|use_case| LlmRouteBinding {
879                        use_case: UseCase(use_case.to_owned()),
880                        model_mode: ModelMode("auto".to_owned()),
881                        revision: Some("1700000000000".to_owned()),
882                        route: LlmRoute {
883                            backend: "openrouter".to_owned(),
884                            profile: "cloud_openrouter".to_owned(),
885                            model: "openai/gpt-5.4-mini".to_owned(),
886                            provider_preferences: vec!["OpenAI".to_owned()],
887                            temperature: Some(0.5),
888                            max_output_tokens: Some(8192),
889                            cache_control: Some(true),
890                            reasoning_effort: None,
891                            context_window_tokens: 128_000,
892                            model_snapshot: LlmModelSnapshot {
893                                capabilities: llm_api::ModelCapabilities {
894                                    vision: true,
895                                    tool_calling: true,
896                                    structured_output: true,
897                                },
898                                generation_support: LlmGenerationSupport {
899                                    temperature: Some(true),
900                                    max_tokens: Some(true),
901                                    reasoning_efforts: Some(vec!["none".into(), "high".into()]),
902                                    temperature_with_reasoning: Some(true),
903                                    ..LlmGenerationSupport::default()
904                                },
905                            },
906                        },
907                    })
908                    .collect(),
909                }),
910                base_checkpoint: None,
911                abandoned_run_id: None,
912            })),
913        };
914        assert_eq!(envelope, expected);
915        assert_eq!(
916            serde_json::to_value(envelope).expect("serializes"),
917            serde_json::from_str::<serde_json::Value>(fixture).expect("fixture JSON")
918        );
919    }
920
921    #[test]
922    fn snapshot_is_required_and_support_metadata_is_strict() {
923        let mut fixture: serde_json::Value = serde_json::from_str(ENQUEUE_FIXTURE).unwrap();
924        fixture["payload"]["payload"]["llm_plan"]["routes"][0]["route"]
925            .as_object_mut()
926            .unwrap()
927            .remove("model_snapshot");
928        assert!(Envelope::decode_json(&fixture.to_string()).is_err());
929        let mut envelope = Envelope::decode_json(ENQUEUE_FIXTURE).unwrap();
930        let Payload::Command(command) = &mut envelope.payload else {
931            panic!("command");
932        };
933        command.llm_plan.as_mut().unwrap().routes[0]
934            .route
935            .model_snapshot
936            .generation_support
937            .max_output_tokens = Some(0);
938        assert!(envelope.validate().is_err());
939    }
940
941    fn dispatch() -> DispatchBinding {
942        DispatchBinding {
943            dispatch_id: "dispatch-1".to_owned(),
944            agent_session_id: "agent-session-1".to_owned(),
945        }
946    }
947
948    fn checkpoint() -> CheckpointReference {
949        CheckpointReference {
950            object_key: "checkpoints/abc.json".to_owned(),
951            sha256: "a".repeat(64),
952            format_version: 1,
953        }
954    }
955
956    #[test]
957    fn every_v1_golden_fixture_round_trips() {
958        let schema: serde_json::Value = serde_json::from_str(include_str!(
959            "../../../schema/execution/envelope.v1.schema.json"
960        ))
961        .expect("valid JSON Schema document");
962        let validator = jsonschema::validator_for(&schema).expect("valid JSON Schema semantics");
963        for fixture in [
964            include_str!("../../../fixtures/execution/enqueue.v1.json"),
965            include_str!("../../../fixtures/execution/completed-event.v1.json"),
966            include_str!("../../../fixtures/execution/interaction-required-event.v1.json"),
967            include_str!("../../../fixtures/execution/failed-event.v1.json"),
968            include_str!("../../../fixtures/execution/app-facade-prepare.v1.json"),
969            include_str!("../../../fixtures/execution/observation.v1.json"),
970            include_str!("../../../fixtures/execution/admission.v1.json"),
971            include_str!("../../../fixtures/execution/app-facade-prepared.v1.json"),
972            include_str!("../../../fixtures/execution/app-facade-user-action.v1.json"),
973            include_str!("../../../fixtures/execution/event-ack.v1.json"),
974            include_str!("../../../fixtures/execution/checkpoint-purge.v1.json"),
975            include_str!("../../../fixtures/execution/checkpoint-purge-ack.v1.json"),
976        ] {
977            let json: serde_json::Value =
978                serde_json::from_str(fixture).expect("valid golden fixture JSON");
979            validator
980                .validate(&json)
981                .expect("golden fixture matches the normative schema");
982            let envelope = Envelope::decode_json(fixture).expect("fixture matches Rust DTOs");
983            assert_eq!(serde_json::to_value(envelope).expect("serializes"), json);
984        }
985    }
986
987    #[test]
988    fn rejects_blank_scoped_identifiers() {
989        let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
990        let mut envelope: Envelope = serde_json::from_str(fixture).expect("valid golden fixture");
991        let Payload::Command(request) = &mut envelope.payload else {
992            panic!("enqueue fixture changed family");
993        };
994        let CommandRequest {
995            command: AgentCommand::Enqueue { context, .. },
996            ..
997        } = request.as_mut()
998        else {
999            panic!("enqueue fixture changed family");
1000        };
1001        context.scope.scope_id = ScopeId::from(" ");
1002        assert_eq!(
1003            envelope.validate(),
1004            Err(ProtocolError::MissingIdentifier("scope_id"))
1005        );
1006    }
1007
1008    #[test]
1009    fn strict_decoder_rejects_unknown_nested_fields() {
1010        let fixture = include_str!("../../../fixtures/execution/enqueue.v1.json");
1011        let input = fixture.replacen(
1012            "\"sent_at_unix_ms\": 1700000000000",
1013            "\"sent_at_unix_ms\": 1700000000000, \"unexpected\": true",
1014            1,
1015        );
1016        assert!(matches!(
1017            Envelope::decode_json(&input),
1018            Err(ProtocolError::UnknownField { .. })
1019        ));
1020
1021        let mut input: serde_json::Value = serde_json::from_str(fixture).expect("fixture JSON");
1022        input["payload"]["payload"]["options"]["unexpected"] = serde_json::Value::Bool(true);
1023        assert!(matches!(
1024            Envelope::decode_json(&input.to_string()),
1025            Err(ProtocolError::UnknownField { .. })
1026        ));
1027    }
1028
1029    #[test]
1030    fn abandoned_run_requires_a_base_checkpoint() {
1031        let mut value: serde_json::Value =
1032            serde_json::from_str(include_str!("../../../fixtures/execution/enqueue.v1.json"))
1033                .unwrap();
1034        value["payload"]["payload"]["abandoned_run_id"] =
1035            serde_json::Value::String("run-abandoned".to_owned());
1036
1037        assert!(matches!(
1038            Envelope::decode_json(&value.to_string()),
1039            Err(ProtocolError::InvalidBinding(_))
1040        ));
1041    }
1042}