Skip to main content

tea_protocol/
record.rs

1use std::str::FromStr;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use serde_json::{Map, Value, json};
5use thiserror::Error;
6
7use crate::content::validate_tool_name;
8use crate::envelope::{deserialize_unique_value, validate_read_version};
9use crate::{
10    ApprovalDecision, ApprovalId, BranchId, CURRENT_PROTOCOL_VERSION, CanonicalMessage,
11    CausationId, ContentBlock, CorrelationId, ProfileId, ProtocolError, ProtocolMetadata,
12    ProtocolTimestamp, ProtocolVersion, RecordId, RunId, SessionId, SessionSequence, ToolCallId,
13    ToolFailure, ToolPresentation, TurnId,
14};
15
16/// Maximum result content blocks stored for one tool execution.
17pub const MAX_RECORD_CONTENT_BLOCKS: usize = 256;
18
19/// Stable initial durable record discriminators.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SessionRecordType {
23    /// A session was created.
24    SessionCreated,
25    /// A canonical message became durable.
26    MessageCommitted,
27    /// Durable model/profile configuration changed.
28    ConfigurationChanged,
29    /// A complete tool request became durable before execution.
30    ToolCallRequested,
31    /// A policy decision was recorded.
32    PolicyDecisionRecorded,
33    /// An approval request became durable.
34    ApprovalRequested,
35    /// A pending approval reached a terminal decision.
36    ApprovalResolved,
37    /// Tool execution began and may have uncertain recovery state.
38    ToolExecutionStarted,
39    /// Tool execution reached a durable terminal result.
40    ToolExecutionFinished,
41    /// Started tool execution was interrupted with uncertain outcome.
42    ToolExecutionInterrupted,
43    /// A provider run was interrupted before terminal output was durable.
44    RunInterrupted,
45    /// A run was explicitly cancelled.
46    RunCancelled,
47    /// A new durable branch was created.
48    BranchCreated,
49    /// The active durable branch changed.
50    ActiveBranchChanged,
51    /// Compaction summary and provenance became durable.
52    SessionCompacted,
53    /// A turn reached the durable boundary required before its next action.
54    TurnCheckpointed,
55}
56
57impl SessionRecordType {
58    /// All initial protocol 1.0 durable record kinds.
59    pub const ALL: [Self; 16] = [
60        Self::SessionCreated,
61        Self::MessageCommitted,
62        Self::ConfigurationChanged,
63        Self::ToolCallRequested,
64        Self::PolicyDecisionRecorded,
65        Self::ApprovalRequested,
66        Self::ApprovalResolved,
67        Self::ToolExecutionStarted,
68        Self::ToolExecutionFinished,
69        Self::ToolExecutionInterrupted,
70        Self::RunInterrupted,
71        Self::RunCancelled,
72        Self::BranchCreated,
73        Self::ActiveBranchChanged,
74        Self::SessionCompacted,
75        Self::TurnCheckpointed,
76    ];
77
78    /// Returns whether replay must understand this record kind.
79    #[must_use]
80    pub const fn is_required_for_replay(self) -> bool {
81        true
82    }
83}
84
85/// Policy outcome persisted before an approval or execution transition.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum PolicyDecision {
89    /// Policy allows execution without interactive approval.
90    Allow,
91    /// Policy denies execution.
92    Deny,
93    /// Policy requires an approval decision.
94    RequireApproval,
95}
96
97/// Tool executor boundary used for a durable invocation.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ExecutionTarget {
101    /// In-process or operating-system-native executor.
102    Native,
103    /// Model Context Protocol executor.
104    Mcp,
105    /// Remote product-defined executor.
106    Remote,
107}
108
109/// Declared retry semantics of a tool execution.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ToolIdempotency {
113    /// Repeating the invocation is expected to have the same external effect.
114    Idempotent,
115    /// Repeating the invocation may duplicate external effects.
116    NonIdempotent,
117    /// Executor can reconcile an operation by a durable external key.
118    ExternallyReconciled,
119}
120
121/// Kernel action allowed after a durable turn checkpoint.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum NextTurnAction {
125    /// Begin another model request.
126    ModelRequest,
127    /// Wait for an approval resolution.
128    WaitForApproval,
129    /// End the current run.
130    FinishRun,
131}
132
133/// A typed durable fact used for deterministic session replay.
134#[derive(Debug, Clone, PartialEq)]
135pub enum SessionRecord {
136    /// A session was created with its initial profile and extension metadata.
137    SessionCreated {
138        /// Initial product profile.
139        profile_id: ProfileId,
140        /// Bounded session metadata.
141        metadata: ProtocolMetadata,
142    },
143    /// A canonical message was committed.
144    MessageCommitted {
145        /// Durable canonical message.
146        message: CanonicalMessage,
147    },
148    /// Model or profile configuration changed.
149    ConfigurationChanged {
150        /// New provider-qualified model when changed.
151        model: Option<crate::ModelRef>,
152        /// New profile when changed.
153        profile_id: Option<ProfileId>,
154        /// New provider-neutral reasoning effort when changed.
155        reasoning_effort: Option<crate::ReasoningEffort>,
156    },
157    /// A complete tool call became durable before policy/execution.
158    ToolCallRequested {
159        /// Canonical tool-call identifier.
160        tool_call_id: ToolCallId,
161        /// Registered tool name.
162        tool_name: String,
163        /// Provider-neutral JSON object arguments.
164        arguments: Value,
165    },
166    /// Policy produced a durable decision for a tool call.
167    PolicyDecisionRecorded {
168        /// Tool call evaluated by policy.
169        tool_call_id: ToolCallId,
170        /// Stable policy outcome.
171        decision: PolicyDecision,
172    },
173    /// Interactive approval became pending.
174    ApprovalRequested {
175        /// Approval request identifier.
176        approval_id: ApprovalId,
177        /// Tool call awaiting approval.
178        tool_call_id: ToolCallId,
179        /// Approval expiry.
180        expires_at: ProtocolTimestamp,
181    },
182    /// Interactive approval reached a terminal decision.
183    ApprovalResolved {
184        /// Approval request identifier.
185        approval_id: ApprovalId,
186        /// Stable approval decision.
187        decision: ApprovalDecision,
188    },
189    /// Tool execution started before crossing an uncertain external boundary.
190    ToolExecutionStarted {
191        /// Tool call being executed.
192        tool_call_id: ToolCallId,
193        /// Executor boundary.
194        execution_target: ExecutionTarget,
195        /// Declared recovery/retry semantics.
196        idempotency: ToolIdempotency,
197    },
198    /// Tool execution reached a durable terminal result.
199    ToolExecutionFinished {
200        /// Tool call that reached a terminal result.
201        tool_call_id: ToolCallId,
202        /// Whether the result is a failure.
203        is_error: bool,
204        /// Canonical display/model result content.
205        content: Vec<ContentBlock>,
206        /// Machine-readable failure, required exactly when `is_error` is true.
207        error: Option<ToolFailure>,
208        /// Optional bounded UI-only presentation retained out of model context.
209        presentation: Option<ToolPresentation>,
210    },
211    /// Started tool execution was interrupted and its outcome is uncertain.
212    ToolExecutionInterrupted {
213        /// Tool call with uncertain outcome.
214        tool_call_id: ToolCallId,
215        /// English technical recovery diagnostic.
216        reason: String,
217    },
218    /// Provider streaming stopped before a terminal message was durable.
219    RunInterrupted {
220        /// Interrupted run.
221        run_id: RunId,
222        /// Active turn when interruption occurred.
223        turn_id: TurnId,
224        /// English technical recovery diagnostic.
225        reason: String,
226    },
227    /// An active run was explicitly cancelled.
228    RunCancelled {
229        /// Cancelled run.
230        run_id: RunId,
231    },
232    /// A new branch was created without rewriting parent history.
233    BranchCreated {
234        /// Source branch.
235        source_branch_id: BranchId,
236        /// New branch.
237        branch_id: BranchId,
238        /// Source record position.
239        from_record_id: RecordId,
240    },
241    /// Active branch leaf changed durably.
242    ActiveBranchChanged {
243        /// New active branch.
244        branch_id: BranchId,
245    },
246    /// A compaction summary was committed with source provenance.
247    SessionCompacted {
248        /// Summary message.
249        summary: CanonicalMessage,
250        /// Last source record replaced in model context.
251        compacted_through_record_id: RecordId,
252    },
253    /// A turn reached a durable checkpoint before its next action.
254    TurnCheckpointed {
255        /// Current run.
256        run_id: RunId,
257        /// Current turn.
258        turn_id: TurnId,
259        /// Action allowed after this durable boundary.
260        next_action: NextTurnAction,
261    },
262}
263
264impl SessionRecord {
265    /// Returns the stable durable record discriminator.
266    #[must_use]
267    pub const fn record_type(&self) -> SessionRecordType {
268        match self {
269            Self::SessionCreated { .. } => SessionRecordType::SessionCreated,
270            Self::MessageCommitted { .. } => SessionRecordType::MessageCommitted,
271            Self::ConfigurationChanged { .. } => SessionRecordType::ConfigurationChanged,
272            Self::ToolCallRequested { .. } => SessionRecordType::ToolCallRequested,
273            Self::PolicyDecisionRecorded { .. } => SessionRecordType::PolicyDecisionRecorded,
274            Self::ApprovalRequested { .. } => SessionRecordType::ApprovalRequested,
275            Self::ApprovalResolved { .. } => SessionRecordType::ApprovalResolved,
276            Self::ToolExecutionStarted { .. } => SessionRecordType::ToolExecutionStarted,
277            Self::ToolExecutionFinished { .. } => SessionRecordType::ToolExecutionFinished,
278            Self::ToolExecutionInterrupted { .. } => SessionRecordType::ToolExecutionInterrupted,
279            Self::RunInterrupted { .. } => SessionRecordType::RunInterrupted,
280            Self::RunCancelled { .. } => SessionRecordType::RunCancelled,
281            Self::BranchCreated { .. } => SessionRecordType::BranchCreated,
282            Self::ActiveBranchChanged { .. } => SessionRecordType::ActiveBranchChanged,
283            Self::SessionCompacted { .. } => SessionRecordType::SessionCompacted,
284            Self::TurnCheckpointed { .. } => SessionRecordType::TurnCheckpointed,
285        }
286    }
287
288    fn validate(&self) -> Result<(), RecordValidationError> {
289        match self {
290            Self::ConfigurationChanged {
291                model,
292                profile_id,
293                reasoning_effort,
294            } if model.is_none() && profile_id.is_none() && reasoning_effort.is_none() => {
295                Err(RecordValidationError::EmptyConfigurationChange)
296            }
297            Self::ToolCallRequested {
298                tool_name,
299                arguments,
300                ..
301            } => {
302                validate_tool_name(tool_name)?;
303                if !arguments.is_object() {
304                    return Err(RecordValidationError::ToolArgumentsMustBeObject);
305                }
306                crate::metadata::validate_json_bounds(
307                    arguments,
308                    crate::MAX_TOOL_ARGUMENT_BYTES,
309                    crate::MAX_TOOL_ARGUMENT_DEPTH,
310                )?;
311                Ok(())
312            }
313            Self::ToolExecutionFinished {
314                is_error,
315                content,
316                error,
317                presentation,
318                ..
319            } => {
320                validate_result_content(content)?;
321                if *is_error != error.is_some() {
322                    return Err(RecordValidationError::InconsistentToolFailure);
323                }
324                if *is_error && presentation.is_some() {
325                    return Err(RecordValidationError::PresentationOnFailure);
326                }
327                Ok(())
328            }
329            Self::ToolExecutionInterrupted { reason, .. } | Self::RunInterrupted { reason, .. } => {
330                validate_reason(reason)
331            }
332            Self::SessionCreated { .. }
333            | Self::MessageCommitted { .. }
334            | Self::ConfigurationChanged { .. }
335            | Self::PolicyDecisionRecorded { .. }
336            | Self::ApprovalRequested { .. }
337            | Self::ApprovalResolved { .. }
338            | Self::ToolExecutionStarted { .. }
339            | Self::RunCancelled { .. }
340            | Self::BranchCreated { .. }
341            | Self::ActiveBranchChanged { .. }
342            | Self::SessionCompacted { .. }
343            | Self::TurnCheckpointed { .. } => Ok(()),
344        }
345    }
346}
347
348#[derive(Serialize, Deserialize)]
349#[serde(
350    remote = "SessionRecord",
351    tag = "type",
352    content = "payload",
353    rename_all = "snake_case"
354)]
355enum SessionRecordDef {
356    SessionCreated {
357        #[serde(rename = "profileId")]
358        profile_id: ProfileId,
359        #[serde(default, skip_serializing_if = "ProtocolMetadata::is_empty")]
360        metadata: ProtocolMetadata,
361    },
362    MessageCommitted {
363        message: CanonicalMessage,
364    },
365    ConfigurationChanged {
366        #[serde(skip_serializing_if = "Option::is_none")]
367        model: Option<crate::ModelRef>,
368        #[serde(rename = "profileId", skip_serializing_if = "Option::is_none")]
369        profile_id: Option<ProfileId>,
370        #[serde(
371            rename = "reasoningEffort",
372            default,
373            skip_serializing_if = "Option::is_none"
374        )]
375        reasoning_effort: Option<crate::ReasoningEffort>,
376    },
377    ToolCallRequested {
378        #[serde(rename = "toolCallId")]
379        tool_call_id: ToolCallId,
380        #[serde(rename = "toolName")]
381        tool_name: String,
382        arguments: Value,
383    },
384    PolicyDecisionRecorded {
385        #[serde(rename = "toolCallId")]
386        tool_call_id: ToolCallId,
387        decision: PolicyDecision,
388    },
389    ApprovalRequested {
390        #[serde(rename = "approvalId")]
391        approval_id: ApprovalId,
392        #[serde(rename = "toolCallId")]
393        tool_call_id: ToolCallId,
394        #[serde(rename = "expiresAt")]
395        expires_at: ProtocolTimestamp,
396    },
397    ApprovalResolved {
398        #[serde(rename = "approvalId")]
399        approval_id: ApprovalId,
400        decision: ApprovalDecision,
401    },
402    ToolExecutionStarted {
403        #[serde(rename = "toolCallId")]
404        tool_call_id: ToolCallId,
405        #[serde(rename = "executionTarget")]
406        execution_target: ExecutionTarget,
407        idempotency: ToolIdempotency,
408    },
409    ToolExecutionFinished {
410        #[serde(rename = "toolCallId")]
411        tool_call_id: ToolCallId,
412        #[serde(rename = "isError")]
413        is_error: bool,
414        content: Vec<ContentBlock>,
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        error: Option<ToolFailure>,
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        presentation: Option<ToolPresentation>,
419    },
420    ToolExecutionInterrupted {
421        #[serde(rename = "toolCallId")]
422        tool_call_id: ToolCallId,
423        reason: String,
424    },
425    RunInterrupted {
426        #[serde(rename = "runId")]
427        run_id: RunId,
428        #[serde(rename = "turnId")]
429        turn_id: TurnId,
430        reason: String,
431    },
432    RunCancelled {
433        #[serde(rename = "runId")]
434        run_id: RunId,
435    },
436    BranchCreated {
437        #[serde(rename = "sourceBranchId")]
438        source_branch_id: BranchId,
439        #[serde(rename = "branchId")]
440        branch_id: BranchId,
441        #[serde(rename = "fromRecordId")]
442        from_record_id: RecordId,
443    },
444    ActiveBranchChanged {
445        #[serde(rename = "branchId")]
446        branch_id: BranchId,
447    },
448    SessionCompacted {
449        summary: CanonicalMessage,
450        #[serde(rename = "compactedThroughRecordId")]
451        compacted_through_record_id: RecordId,
452    },
453    TurnCheckpointed {
454        #[serde(rename = "runId")]
455        run_id: RunId,
456        #[serde(rename = "turnId")]
457        turn_id: TurnId,
458        #[serde(rename = "nextAction")]
459        next_action: NextTurnAction,
460    },
461}
462
463impl Serialize for SessionRecord {
464    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
465    where
466        S: Serializer,
467    {
468        self.validate().map_err(serde::ser::Error::custom)?;
469        SessionRecordDef::serialize(self, serializer)
470    }
471}
472
473impl<'de> Deserialize<'de> for SessionRecord {
474    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
475    where
476        D: Deserializer<'de>,
477    {
478        let record = SessionRecordDef::deserialize(deserializer)?;
479        record.validate().map_err(serde::de::Error::custom)?;
480        Ok(record)
481    }
482}
483
484/// Versioned durable session-record envelope.
485#[derive(Debug, Clone, PartialEq)]
486pub struct RecordEnvelope {
487    protocol_version: ProtocolVersion,
488    record_id: RecordId,
489    session_id: SessionId,
490    sequence: SessionSequence,
491    timestamp: ProtocolTimestamp,
492    causation_id: Option<CausationId>,
493    correlation_id: Option<CorrelationId>,
494    branch_id: Option<BranchId>,
495    metadata: ProtocolMetadata,
496    record: SessionRecord,
497}
498
499impl RecordEnvelope {
500    /// Creates a validated current-version durable record envelope.
501    ///
502    /// # Errors
503    ///
504    /// Returns an error when the record payload or branch references are invalid.
505    #[allow(clippy::too_many_arguments)]
506    pub fn new(
507        record_id: RecordId,
508        session_id: SessionId,
509        sequence: SessionSequence,
510        timestamp: ProtocolTimestamp,
511        causation_id: Option<CausationId>,
512        correlation_id: Option<CorrelationId>,
513        branch_id: Option<BranchId>,
514        metadata: ProtocolMetadata,
515        record: SessionRecord,
516    ) -> Result<Self, RecordValidationError> {
517        let envelope = Self {
518            protocol_version: CURRENT_PROTOCOL_VERSION,
519            record_id,
520            session_id,
521            sequence,
522            timestamp,
523            causation_id,
524            correlation_id,
525            branch_id,
526            metadata,
527            record,
528        };
529        envelope.validate()?;
530        Ok(envelope)
531    }
532
533    /// Decodes untrusted JSON while preserving unsupported-record classification.
534    ///
535    /// # Errors
536    ///
537    /// Returns [`RecordDecodeError::UnsupportedType`] for an unknown canonical
538    /// discriminator and [`RecordDecodeError::Invalid`] for malformed data.
539    pub fn decode_value(value: Value) -> Result<Self, RecordDecodeError> {
540        let version = decode_version(&value).map_err(RecordDecodeError::Invalid)?;
541        if validate_read_version(version).is_err() {
542            return Err(RecordDecodeError::UnsupportedVersion { version });
543        }
544        let discriminator = value
545            .as_object()
546            .and_then(|object| object.get("type"))
547            .and_then(Value::as_str)
548            .ok_or_else(|| RecordDecodeError::Invalid("missing record type".to_owned()))?
549            .to_owned();
550        if SessionRecordTypeText::from_str(&discriminator).is_err() {
551            if valid_discriminator(&discriminator) {
552                return Err(RecordDecodeError::UnsupportedType {
553                    record_type: discriminator,
554                });
555            }
556            return Err(RecordDecodeError::Invalid("invalid record type".to_owned()));
557        }
558        serde_json::from_value(value).map_err(|error| RecordDecodeError::Invalid(error.to_string()))
559    }
560
561    /// Returns the protocol version.
562    #[must_use]
563    pub const fn protocol_version(&self) -> ProtocolVersion {
564        self.protocol_version
565    }
566
567    /// Returns the record identifier.
568    #[must_use]
569    pub const fn record_id(&self) -> RecordId {
570        self.record_id
571    }
572
573    /// Returns the owning session.
574    #[must_use]
575    pub const fn session_id(&self) -> SessionId {
576        self.session_id
577    }
578
579    /// Returns the authoritative replay sequence.
580    #[must_use]
581    pub const fn sequence(&self) -> SessionSequence {
582        self.sequence
583    }
584
585    /// Returns the record timestamp.
586    #[must_use]
587    pub const fn timestamp(&self) -> ProtocolTimestamp {
588        self.timestamp
589    }
590
591    /// Returns the optional immediate cause.
592    #[must_use]
593    pub const fn causation_id(&self) -> Option<CausationId> {
594        self.causation_id
595    }
596
597    /// Returns the optional operation correlation identifier.
598    #[must_use]
599    pub const fn correlation_id(&self) -> Option<CorrelationId> {
600        self.correlation_id
601    }
602
603    /// Returns the branch receiving this fact when branch-scoped.
604    #[must_use]
605    pub const fn branch_id(&self) -> Option<BranchId> {
606        self.branch_id
607    }
608
609    /// Returns bounded extension metadata.
610    #[must_use]
611    pub const fn metadata(&self) -> &ProtocolMetadata {
612        &self.metadata
613    }
614
615    /// Returns the typed durable fact.
616    #[must_use]
617    pub const fn record(&self) -> &SessionRecord {
618        &self.record
619    }
620
621    /// Returns the stable durable record discriminator.
622    #[must_use]
623    pub const fn record_type(&self) -> SessionRecordType {
624        self.record.record_type()
625    }
626
627    fn validate(&self) -> Result<(), RecordValidationError> {
628        self.record.validate()?;
629        match &self.record {
630            SessionRecord::BranchCreated { branch_id, .. }
631            | SessionRecord::ActiveBranchChanged { branch_id }
632                if self.branch_id != Some(*branch_id) =>
633            {
634                Err(RecordValidationError::BranchReferenceMismatch)
635            }
636            _ => Ok(()),
637        }
638    }
639}
640
641impl Serialize for RecordEnvelope {
642    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
643    where
644        S: Serializer,
645    {
646        self.validate().map_err(serde::ser::Error::custom)?;
647        let mut value = serde_json::to_value(&self.record).map_err(serde::ser::Error::custom)?;
648        let object = value
649            .as_object_mut()
650            .ok_or_else(|| serde::ser::Error::custom("record must encode as object"))?;
651        object.insert("protocolVersion".to_owned(), json!(self.protocol_version));
652        object.insert("recordId".to_owned(), json!(self.record_id));
653        object.insert("sessionId".to_owned(), json!(self.session_id));
654        object.insert("sequence".to_owned(), json!(self.sequence));
655        object.insert("timestamp".to_owned(), json!(self.timestamp));
656        if let Some(causation_id) = self.causation_id {
657            object.insert("causationId".to_owned(), json!(causation_id));
658        }
659        if let Some(correlation_id) = self.correlation_id {
660            object.insert("correlationId".to_owned(), json!(correlation_id));
661        }
662        if let Some(branch_id) = self.branch_id {
663            object.insert("branchId".to_owned(), json!(branch_id));
664        }
665        if !self.metadata.is_empty() {
666            object.insert("metadata".to_owned(), json!(self.metadata));
667        }
668        value.serialize(serializer)
669    }
670}
671
672impl<'de> Deserialize<'de> for RecordEnvelope {
673    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
674    where
675        D: Deserializer<'de>,
676    {
677        let mut value = deserialize_unique_value(deserializer)?;
678        let object = value
679            .as_object_mut()
680            .ok_or_else(|| serde::de::Error::custom("record envelope must be an object"))?;
681        let protocol_version = take(object, "protocolVersion").map_err(serde::de::Error::custom)?;
682        validate_read_version(protocol_version).map_err(serde::de::Error::custom)?;
683        let envelope = Self {
684            protocol_version,
685            record_id: take(object, "recordId").map_err(serde::de::Error::custom)?,
686            session_id: take(object, "sessionId").map_err(serde::de::Error::custom)?,
687            sequence: take(object, "sequence").map_err(serde::de::Error::custom)?,
688            timestamp: take(object, "timestamp").map_err(serde::de::Error::custom)?,
689            causation_id: take_optional(object, "causationId").map_err(serde::de::Error::custom)?,
690            correlation_id: take_optional(object, "correlationId")
691                .map_err(serde::de::Error::custom)?,
692            branch_id: take_optional(object, "branchId").map_err(serde::de::Error::custom)?,
693            metadata: take_optional(object, "metadata")
694                .map_err(serde::de::Error::custom)?
695                .unwrap_or_default(),
696            record: SessionRecord::deserialize(Value::Object(std::mem::take(object)))
697                .map_err(serde::de::Error::custom)?,
698        };
699        envelope.validate().map_err(serde::de::Error::custom)?;
700        Ok(envelope)
701    }
702}
703
704/// Failure while decoding an untrusted durable record.
705#[derive(Debug, Error)]
706pub enum RecordDecodeError {
707    /// The protocol major is unsupported and takes precedence over record type.
708    #[error("unsupported protocol version: {version}")]
709    UnsupportedVersion {
710        /// Received canonical protocol version.
711        version: ProtocolVersion,
712    },
713    /// Replay cannot safely understand this required record kind.
714    #[error("unsupported required record type: {record_type}")]
715    UnsupportedType {
716        /// Bounded canonical unknown discriminator.
717        record_type: String,
718    },
719    /// The known record or envelope is malformed.
720    #[error("invalid durable record: {0}")]
721    Invalid(String),
722}
723
724impl RecordDecodeError {
725    /// Converts a decode failure to a safe protocol error.
726    #[must_use]
727    pub fn into_protocol_error(self, correlation_id: CorrelationId) -> ProtocolError {
728        match self {
729            Self::UnsupportedVersion { version } => {
730                ProtocolError::unsupported_protocol_version(correlation_id, version)
731            }
732            Self::UnsupportedType { record_type } => {
733                ProtocolError::unsupported_record(correlation_id, &record_type)
734            }
735            Self::Invalid(_) => ProtocolError::invalid_record(correlation_id),
736        }
737    }
738}
739
740/// Error returned when validating a durable record payload.
741#[derive(Debug, Error)]
742pub enum RecordValidationError {
743    /// A configuration record does not change any typed setting.
744    #[error("configuration_changed must include modelId or profileId")]
745    EmptyConfigurationChange,
746    /// Tool name or arguments are invalid.
747    #[error("tool call is invalid: {0}")]
748    InvalidToolCall(#[from] crate::ContentValidationError),
749    /// Tool arguments are not a JSON object.
750    #[error("tool arguments must be a JSON object")]
751    ToolArgumentsMustBeObject,
752    /// Tool arguments exceed protocol JSON bounds.
753    #[error("tool arguments exceed protocol bounds: {0}")]
754    ToolArgumentsOutOfBounds(#[from] crate::ProtocolMetadataError),
755    /// Tool terminal content is empty, excessive, or contains invalid blocks.
756    #[error("tool terminal content is invalid")]
757    InvalidToolResultContent,
758    /// Branch-scoped envelope and payload identifiers disagree.
759    #[error("record envelope branchId must match the branch payload")]
760    BranchReferenceMismatch,
761    /// Tool error flag does not match machine-readable failure presence.
762    #[error("tool terminal isError must match error presence")]
763    InconsistentToolFailure,
764    /// A failed execution cannot claim a successful change presentation.
765    #[error("failed tool execution cannot include a presentation")]
766    PresentationOnFailure,
767    /// Interruption diagnostic is empty, oversized, or contains a null character.
768    #[error("interruption reason is invalid")]
769    InvalidInterruptionReason,
770}
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773struct SessionRecordTypeText;
774
775impl FromStr for SessionRecordTypeText {
776    type Err = ();
777
778    fn from_str(value: &str) -> Result<Self, Self::Err> {
779        if [
780            "session_created",
781            "message_committed",
782            "configuration_changed",
783            "tool_call_requested",
784            "policy_decision_recorded",
785            "approval_requested",
786            "approval_resolved",
787            "tool_execution_started",
788            "tool_execution_finished",
789            "tool_execution_interrupted",
790            "run_interrupted",
791            "run_cancelled",
792            "branch_created",
793            "active_branch_changed",
794            "session_compacted",
795            "turn_checkpointed",
796        ]
797        .contains(&value)
798        {
799            Ok(Self)
800        } else {
801            Err(())
802        }
803    }
804}
805
806fn decode_version(value: &Value) -> Result<ProtocolVersion, String> {
807    let version = value
808        .as_object()
809        .and_then(|object| object.get("protocolVersion"))
810        .cloned()
811        .ok_or_else(|| "missing protocolVersion".to_owned())?;
812    serde_json::from_value(version).map_err(|error| error.to_string())
813}
814
815fn validate_result_content(content: &[ContentBlock]) -> Result<(), RecordValidationError> {
816    if content.is_empty()
817        || content.len() > MAX_RECORD_CONTENT_BLOCKS
818        || !content.iter().all(ContentBlock::valid_for_tool_result)
819    {
820        return Err(RecordValidationError::InvalidToolResultContent);
821    }
822    for block in content {
823        block
824            .validate()
825            .map_err(RecordValidationError::InvalidToolCall)?;
826    }
827    Ok(())
828}
829
830fn validate_reason(reason: &str) -> Result<(), RecordValidationError> {
831    if reason.is_empty() || reason.len() > 4096 || reason.contains('\0') {
832        Err(RecordValidationError::InvalidInterruptionReason)
833    } else {
834        Ok(())
835    }
836}
837
838fn valid_discriminator(value: &str) -> bool {
839    !value.is_empty()
840        && value.len() <= 128
841        && value
842            .bytes()
843            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
844}
845
846fn take<T>(object: &mut Map<String, Value>, key: &str) -> Result<T, serde_json::Error>
847where
848    T: for<'de> Deserialize<'de>,
849{
850    serde_json::from_value(object.remove(key).unwrap_or(Value::Null))
851}
852
853fn take_optional<T>(
854    object: &mut Map<String, Value>,
855    key: &str,
856) -> Result<Option<T>, serde_json::Error>
857where
858    T: for<'de> Deserialize<'de>,
859{
860    object.remove(key).map_or(Ok(None), serde_json::from_value)
861}