Skip to main content

bamboo_domain/session/
provider_transcript.rs

1//! Durable, provider-native transcript items used by progressive tool discovery.
2//!
3//! This lane is deliberately separate from [`Session::messages`]. The latter is
4//! the provider-neutral, user-visible transcript; this module preserves the
5//! small set of provider-owned items whose exact position carries tool-loading
6//! state. Raw JSON is admitted only through the validators below, so persisted
7//! data cannot become an unrestricted request-injection channel.
8
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11
12use serde::de::Error as _;
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18use super::Session;
19
20pub const PROVIDER_TRANSCRIPT_SCHEMA_VERSION: u32 = 1;
21const ITEM_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-item/v1\0";
22const GROUP_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-group/v1\0";
23const ANCHOR_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-anchor/v1\0";
24const TRANSCRIPT_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-state/v1\0";
25const PROVIDER_BOUNDARY_HASH_DOMAIN: &[u8] = b"bamboo/provider-transcript-boundary/v1\0";
26
27/// Provider identity boundary for native transcript replay.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ProviderFamily {
31    OpenAi,
32    Anthropic,
33    Copilot,
34}
35
36impl ProviderFamily {
37    /// Resolve Bamboo's underlying provider type into a native replay family.
38    /// Unknown/compatibility providers intentionally return `None`.
39    pub fn from_provider_type(provider_type: Option<&str>) -> Option<Self> {
40        match provider_type.map(str::trim) {
41            Some("openai") => Some(Self::OpenAi),
42            Some("anthropic") => Some(Self::Anthropic),
43            Some("copilot") => Some(Self::Copilot),
44            _ => None,
45        }
46    }
47}
48
49/// Concrete wire protocol whose items may be replayed.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ProviderProtocol {
53    OpenAiResponsesV1,
54    AnthropicMessages2023_06_01,
55}
56
57impl ProviderProtocol {
58    pub fn supports_family(self, family: ProviderFamily) -> bool {
59        match self {
60            Self::OpenAiResponsesV1 => {
61                matches!(family, ProviderFamily::OpenAi | ProviderFamily::Copilot)
62            }
63            Self::AnthropicMessages2023_06_01 => family == ProviderFamily::Anthropic,
64        }
65    }
66}
67
68/// Derive a secret-free provider routing boundary for native replay. The raw
69/// provider instance id/type is never persisted or emitted by diagnostics; the
70/// digest is sufficient to distinguish same-family instances across restart
71/// and append-safe merge.
72pub fn provider_transcript_boundary_sha256(
73    provider_name: Option<&str>,
74    provider_type: Option<&str>,
75) -> Option<String> {
76    let provider_name = provider_name
77        .map(str::trim)
78        .filter(|value| !value.is_empty());
79    let provider_type = provider_type
80        .map(str::trim)
81        .filter(|value| !value.is_empty());
82    if provider_name.is_none() && provider_type.is_none() {
83        return None;
84    }
85    Some(hash_json(
86        PROVIDER_BOUNDARY_HASH_DOMAIN,
87        &serde_json::json!({
88            "provider_name": provider_name,
89            "provider_type": provider_type.map(str::to_ascii_lowercase),
90        }),
91    ))
92}
93
94fn unbound_provider_boundary_sha256() -> String {
95    hash_bytes(PROVIDER_BOUNDARY_HASH_DOMAIN, b"unbound")
96}
97
98fn is_sha256_hex(value: &str) -> bool {
99    value.len() == 64
100        && value
101            .bytes()
102            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum ProviderTranscriptOrigin {
108    Provider,
109    HostToolSearch,
110    DeveloperContext,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ProviderTranscriptAuthor {
116    Model,
117    Host,
118    ToolResult,
119}
120
121/// The bounded provider item variants Bamboo is willing to replay.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum ProviderTranscriptItemKind {
125    OpenAiMessage,
126    OpenAiReasoning,
127    OpenAiFunctionCall,
128    OpenAiToolSearchCall,
129    OpenAiToolSearchOutput,
130    OpenAiAdditionalTools,
131    AnthropicText,
132    AnthropicThinking,
133    AnthropicRedactedThinking,
134    AnthropicServerToolUse,
135    AnthropicToolSearchToolResult,
136    AnthropicToolUse,
137    AnthropicToolResult,
138}
139
140impl ProviderTranscriptItemKind {
141    pub fn is_discovery(self) -> bool {
142        matches!(
143            self,
144            Self::OpenAiToolSearchCall
145                | Self::OpenAiToolSearchOutput
146                | Self::OpenAiAdditionalTools
147                | Self::AnthropicServerToolUse
148                | Self::AnthropicToolSearchToolResult
149                | Self::AnthropicToolResult
150        )
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ProviderTranscriptResetReason {
157    ProviderSwitch,
158    Compression,
159    Rollback,
160    HardTruncation,
161    CacheScopeChanged,
162    RetentionLimit,
163    ExplicitHistoryRewrite,
164}
165
166#[derive(Debug, Error, Clone, PartialEq, Eq)]
167pub enum ProviderTranscriptError {
168    #[error("provider transcript payload must be a JSON object")]
169    PayloadNotObject,
170    #[error("unsupported provider transcript item type")]
171    UnsupportedItemType,
172    #[error("invalid provider transcript item: {0}")]
173    InvalidItem(&'static str),
174    #[error("provider family and protocol do not match")]
175    FamilyProtocolMismatch,
176    #[error("provider transcript group must contain at least one item")]
177    EmptyGroup,
178    #[error("provider transcript group has no discovery item")]
179    MissingDiscoveryItem,
180    #[error("provider transcript group mixes provider families or protocols")]
181    MixedProviderGroup,
182    #[error("provider transcript group anchor is empty or missing")]
183    InvalidAnchor,
184    #[error("provider transcript group order is invalid")]
185    InvalidGroupOrder,
186    #[error("provider transcript state contains duplicate group ids")]
187    DuplicateGroupId,
188    #[error("provider transcript state contains an invalid current-epoch sequence")]
189    InvalidStateSequence,
190    #[error("provider transcript state contains a group from a future epoch")]
191    FutureGroupEpoch,
192    #[error("provider transcript state current epoch does not match its active family")]
193    CurrentEpochFamilyMismatch,
194    #[error("provider transcript state current epoch does not match its active protocol")]
195    CurrentEpochProtocolMismatch,
196    #[error("provider transcript state has an invalid provider boundary")]
197    InvalidProviderBoundary,
198    #[error("provider transcript item belongs to a non-active provider route")]
199    InactiveProviderRoute,
200}
201
202/// One exact provider-owned item. `payload` stays private so callers cannot
203/// bypass validation after construction.
204#[derive(Clone, PartialEq, Serialize)]
205pub struct ProviderTranscriptItem {
206    family: ProviderFamily,
207    protocol: ProviderProtocol,
208    origin: ProviderTranscriptOrigin,
209    author: ProviderTranscriptAuthor,
210    kind: ProviderTranscriptItemKind,
211    id: String,
212    payload: Value,
213}
214
215#[derive(Deserialize)]
216struct ProviderTranscriptItemWire {
217    family: ProviderFamily,
218    protocol: ProviderProtocol,
219    origin: ProviderTranscriptOrigin,
220    author: ProviderTranscriptAuthor,
221    kind: ProviderTranscriptItemKind,
222    id: String,
223    payload: Value,
224}
225
226impl<'de> Deserialize<'de> for ProviderTranscriptItem {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: Deserializer<'de>,
230    {
231        let wire = ProviderTranscriptItemWire::deserialize(deserializer)?;
232        let item = Self::try_from_payload(
233            wire.family,
234            wire.protocol,
235            wire.origin,
236            wire.author,
237            wire.payload,
238        )
239        .map_err(D::Error::custom)?;
240        if item.kind != wire.kind || item.id != wire.id {
241            return Err(D::Error::custom(
242                "provider transcript kind/id does not match its validated payload",
243            ));
244        }
245        Ok(item)
246    }
247}
248
249impl ProviderTranscriptItem {
250    pub fn try_from_payload(
251        family: ProviderFamily,
252        protocol: ProviderProtocol,
253        origin: ProviderTranscriptOrigin,
254        author: ProviderTranscriptAuthor,
255        payload: Value,
256    ) -> Result<Self, ProviderTranscriptError> {
257        if !protocol.supports_family(family) {
258            return Err(ProviderTranscriptError::FamilyProtocolMismatch);
259        }
260        let kind = infer_and_validate_item(protocol, origin, author, &payload)?;
261        let id = stable_item_id(family, protocol, origin, author, kind, &payload)?;
262        Ok(Self {
263            family,
264            protocol,
265            origin,
266            author,
267            kind,
268            id,
269            payload,
270        })
271    }
272
273    pub fn family(&self) -> ProviderFamily {
274        self.family
275    }
276
277    pub fn protocol(&self) -> ProviderProtocol {
278        self.protocol
279    }
280
281    pub fn origin(&self) -> ProviderTranscriptOrigin {
282        self.origin
283    }
284
285    pub fn author(&self) -> ProviderTranscriptAuthor {
286        self.author
287    }
288
289    pub fn kind(&self) -> ProviderTranscriptItemKind {
290        self.kind
291    }
292
293    pub fn id(&self) -> &str {
294        &self.id
295    }
296
297    pub fn payload(&self) -> &Value {
298        &self.payload
299    }
300
301    pub fn payload_sha256(&self) -> String {
302        hash_json(ITEM_HASH_DOMAIN, &self.payload)
303    }
304}
305
306/// Debug output is deliberately payload-free. Provider items may include tool
307/// schemas, arguments, paths, or opaque provider fields.
308impl fmt::Debug for ProviderTranscriptItem {
309    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
310        formatter
311            .debug_struct("ProviderTranscriptItem")
312            .field("family", &self.family)
313            .field("protocol", &self.protocol)
314            .field("origin", &self.origin)
315            .field("author", &self.author)
316            .field("kind", &self.kind)
317            .field("id", &self.id)
318            .field("payload_sha256", &self.payload_sha256())
319            .finish()
320    }
321}
322
323/// Items that must survive or be removed together, anchored at one ordinary
324/// transcript message. The anchor gives later provider adapters an exact
325/// chronological insertion/replacement point without putting raw items inside
326/// [`super::Message`].
327#[derive(Clone, PartialEq, Serialize)]
328pub struct ProviderTranscriptGroup {
329    id: String,
330    epoch: u64,
331    sequence: u64,
332    anchor_message_id: String,
333    family: ProviderFamily,
334    protocol: ProviderProtocol,
335    provider_boundary_sha256: String,
336    items: Vec<ProviderTranscriptItem>,
337}
338
339#[derive(Deserialize)]
340struct ProviderTranscriptGroupWire {
341    id: String,
342    epoch: u64,
343    sequence: u64,
344    anchor_message_id: String,
345    family: ProviderFamily,
346    protocol: ProviderProtocol,
347    provider_boundary_sha256: String,
348    items: Vec<ProviderTranscriptItem>,
349}
350
351impl<'de> Deserialize<'de> for ProviderTranscriptGroup {
352    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
353    where
354        D: Deserializer<'de>,
355    {
356        let wire = ProviderTranscriptGroupWire::deserialize(deserializer)?;
357        let group = Self::new(
358            wire.epoch,
359            wire.sequence,
360            wire.anchor_message_id,
361            Some(wire.id.as_str()),
362            wire.provider_boundary_sha256,
363            wire.items,
364        )
365        .map_err(D::Error::custom)?;
366        if group.id != wire.id || group.family != wire.family || group.protocol != wire.protocol {
367            return Err(D::Error::custom(
368                "provider transcript group identity does not match its validated items",
369            ));
370        }
371        Ok(group)
372    }
373}
374
375impl ProviderTranscriptGroup {
376    /// Validate an atomic provider-native batch before any item is exposed to
377    /// the engine stream. Provider adapters use this same admission path as
378    /// durable group construction so malformed ordering cannot fail later,
379    /// after the normalized assistant response has already been committed.
380    pub fn validate_items(items: &[ProviderTranscriptItem]) -> Result<(), ProviderTranscriptError> {
381        validated_group_identity(items).map(|_| ())
382    }
383
384    fn new(
385        epoch: u64,
386        sequence: u64,
387        anchor_message_id: String,
388        id_hint: Option<&str>,
389        provider_boundary_sha256: String,
390        items: Vec<ProviderTranscriptItem>,
391    ) -> Result<Self, ProviderTranscriptError> {
392        if anchor_message_id.trim().is_empty() {
393            return Err(ProviderTranscriptError::InvalidAnchor);
394        }
395        let Some(first) = items.first() else {
396            return Err(ProviderTranscriptError::EmptyGroup);
397        };
398        let family = first.family;
399        let protocol = first.protocol;
400        if !is_sha256_hex(&provider_boundary_sha256) {
401            return Err(ProviderTranscriptError::InvalidProviderBoundary);
402        }
403        Self::validate_items(&items)?;
404
405        let id = stable_group_id(
406            epoch,
407            family,
408            protocol,
409            &provider_boundary_sha256,
410            &anchor_message_id,
411            id_hint,
412            &items,
413        );
414        Ok(Self {
415            id,
416            epoch,
417            sequence,
418            anchor_message_id,
419            family,
420            protocol,
421            provider_boundary_sha256,
422            items,
423        })
424    }
425
426    pub fn id(&self) -> &str {
427        &self.id
428    }
429
430    pub fn epoch(&self) -> u64 {
431        self.epoch
432    }
433
434    pub fn sequence(&self) -> u64 {
435        self.sequence
436    }
437
438    pub fn anchor_message_id(&self) -> &str {
439        &self.anchor_message_id
440    }
441
442    pub fn family(&self) -> ProviderFamily {
443        self.family
444    }
445
446    pub fn protocol(&self) -> ProviderProtocol {
447        self.protocol
448    }
449
450    pub fn provider_boundary_sha256(&self) -> &str {
451        &self.provider_boundary_sha256
452    }
453
454    pub fn items(&self) -> &[ProviderTranscriptItem] {
455        &self.items
456    }
457}
458
459impl fmt::Debug for ProviderTranscriptGroup {
460    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
461        formatter
462            .debug_struct("ProviderTranscriptGroup")
463            .field("id", &self.id)
464            .field("epoch", &self.epoch)
465            .field("sequence", &self.sequence)
466            .field(
467                "anchor_message_sha256",
468                &hash_bytes(ANCHOR_HASH_DOMAIN, self.anchor_message_id.as_bytes()),
469            )
470            .field("family", &self.family)
471            .field("protocol", &self.protocol)
472            .field("provider_boundary_sha256", &self.provider_boundary_sha256)
473            .field(
474                "item_kinds",
475                &self.items.iter().map(|item| item.kind).collect::<Vec<_>>(),
476            )
477            .finish()
478    }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
482pub struct ProviderTranscriptDiagnostics {
483    pub group_count: usize,
484    pub item_count: usize,
485    pub serialized_bytes: usize,
486    pub sha256: String,
487}
488
489/// Durable native transcript state. Only groups in the active family and
490/// current epoch are replayable; earlier epochs remain persisted for audit and
491/// normalized fallback but can never cross a provider switch.
492#[derive(Clone, PartialEq, Serialize)]
493pub struct ProviderTranscriptState {
494    schema_version: u32,
495    state_revision: u64,
496    epoch: u64,
497    next_sequence: u64,
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    active_family: Option<ProviderFamily>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    active_protocol: Option<ProviderProtocol>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    active_provider_boundary_sha256: Option<String>,
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    groups: Vec<ProviderTranscriptGroup>,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    last_reset_reason: Option<ProviderTranscriptResetReason>,
508}
509
510#[derive(Deserialize)]
511struct ProviderTranscriptStateWire {
512    #[serde(default)]
513    schema_version: Option<u32>,
514    #[serde(default)]
515    state_revision: u64,
516    #[serde(default)]
517    epoch: u64,
518    #[serde(default)]
519    next_sequence: u64,
520    #[serde(default)]
521    active_family: Option<ProviderFamily>,
522    #[serde(default)]
523    active_protocol: Option<ProviderProtocol>,
524    #[serde(default)]
525    active_provider_boundary_sha256: Option<String>,
526    #[serde(default)]
527    groups: Vec<ProviderTranscriptGroup>,
528    #[serde(default)]
529    last_reset_reason: Option<ProviderTranscriptResetReason>,
530}
531
532impl<'de> Deserialize<'de> for ProviderTranscriptState {
533    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
534    where
535        D: Deserializer<'de>,
536    {
537        let wire = ProviderTranscriptStateWire::deserialize(deserializer)?;
538        let wire_is_empty = wire.state_revision == 0
539            && wire.epoch == 0
540            && wire.next_sequence == 0
541            && wire.active_family.is_none()
542            && wire.active_protocol.is_none()
543            && wire.active_provider_boundary_sha256.is_none()
544            && wire.groups.is_empty()
545            && wire.last_reset_reason.is_none();
546        let schema_version = match wire.schema_version {
547            Some(PROVIDER_TRANSCRIPT_SCHEMA_VERSION) => PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
548            Some(_) => {
549                return Err(D::Error::custom(
550                    "unsupported provider transcript schema version",
551                ))
552            }
553            None if wire_is_empty => PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
554            None => {
555                return Err(D::Error::custom(
556                    "missing provider transcript schema version",
557                ))
558            }
559        };
560        let active_route_fields = [
561            wire.active_family.is_some(),
562            wire.active_protocol.is_some(),
563            wire.active_provider_boundary_sha256.is_some(),
564        ];
565        if active_route_fields.iter().any(|present| *present)
566            && !active_route_fields.iter().all(|present| *present)
567        {
568            return Err(D::Error::custom(
569                ProviderTranscriptError::InvalidProviderBoundary,
570            ));
571        }
572        if wire
573            .active_provider_boundary_sha256
574            .as_deref()
575            .is_some_and(|boundary| !is_sha256_hex(boundary))
576        {
577            return Err(D::Error::custom(
578                ProviderTranscriptError::InvalidProviderBoundary,
579            ));
580        }
581        if let (Some(family), Some(protocol)) = (wire.active_family, wire.active_protocol) {
582            if !protocol.supports_family(family) {
583                return Err(D::Error::custom(
584                    ProviderTranscriptError::FamilyProtocolMismatch,
585                ));
586            }
587        }
588        let mut ids = HashSet::new();
589        if wire
590            .groups
591            .iter()
592            .any(|group| !ids.insert(group.id.clone()))
593        {
594            return Err(D::Error::custom(ProviderTranscriptError::DuplicateGroupId));
595        }
596        if wire.groups.iter().any(|group| group.epoch > wire.epoch) {
597            return Err(D::Error::custom(ProviderTranscriptError::FutureGroupEpoch));
598        }
599        let mut current_sequences = HashSet::new();
600        let mut current_max_sequence = None;
601        for group in wire.groups.iter().filter(|group| group.epoch == wire.epoch) {
602            if wire.active_family != Some(group.family) {
603                return Err(D::Error::custom(
604                    ProviderTranscriptError::CurrentEpochFamilyMismatch,
605                ));
606            }
607            if wire.active_protocol != Some(group.protocol) {
608                return Err(D::Error::custom(
609                    ProviderTranscriptError::CurrentEpochProtocolMismatch,
610                ));
611            }
612            if wire.active_provider_boundary_sha256.as_deref()
613                != Some(group.provider_boundary_sha256())
614            {
615                return Err(D::Error::custom(
616                    ProviderTranscriptError::InvalidProviderBoundary,
617                ));
618            }
619            if !current_sequences.insert(group.sequence) {
620                return Err(D::Error::custom(
621                    ProviderTranscriptError::InvalidStateSequence,
622                ));
623            }
624            current_max_sequence = Some(
625                current_max_sequence
626                    .map_or(group.sequence, |current: u64| current.max(group.sequence)),
627            );
628        }
629        if current_max_sequence.is_some_and(|sequence| sequence >= wire.next_sequence) {
630            return Err(D::Error::custom(
631                ProviderTranscriptError::InvalidStateSequence,
632            ));
633        }
634        Ok(Self {
635            schema_version,
636            state_revision: wire.state_revision,
637            epoch: wire.epoch,
638            next_sequence: wire.next_sequence,
639            active_family: wire.active_family,
640            active_protocol: wire.active_protocol,
641            active_provider_boundary_sha256: wire.active_provider_boundary_sha256,
642            groups: wire.groups,
643            last_reset_reason: wire.last_reset_reason,
644        })
645    }
646}
647
648impl Default for ProviderTranscriptState {
649    fn default() -> Self {
650        Self {
651            schema_version: PROVIDER_TRANSCRIPT_SCHEMA_VERSION,
652            state_revision: 0,
653            epoch: 0,
654            next_sequence: 0,
655            active_family: None,
656            active_protocol: None,
657            active_provider_boundary_sha256: None,
658            groups: Vec::new(),
659            last_reset_reason: None,
660        }
661    }
662}
663
664impl fmt::Debug for ProviderTranscriptState {
665    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
666        formatter
667            .debug_struct("ProviderTranscriptState")
668            .field("schema_version", &self.schema_version)
669            .field("state_revision", &self.state_revision)
670            .field("epoch", &self.epoch)
671            .field("next_sequence", &self.next_sequence)
672            .field("active_family", &self.active_family)
673            .field("active_protocol", &self.active_protocol)
674            .field(
675                "active_provider_boundary_sha256",
676                &self.active_provider_boundary_sha256,
677            )
678            .field("groups", &self.groups)
679            .field("last_reset_reason", &self.last_reset_reason)
680            .finish()
681    }
682}
683
684impl ProviderTranscriptState {
685    pub fn is_empty(&self) -> bool {
686        self.state_revision == 0
687            && self.epoch == 0
688            && self.next_sequence == 0
689            && self.active_family.is_none()
690            && self.active_protocol.is_none()
691            && self.active_provider_boundary_sha256.is_none()
692            && self.groups.is_empty()
693            && self.last_reset_reason.is_none()
694    }
695
696    pub fn state_revision(&self) -> u64 {
697        self.state_revision
698    }
699
700    pub fn epoch(&self) -> u64 {
701        self.epoch
702    }
703
704    pub fn active_family(&self) -> Option<ProviderFamily> {
705        self.active_family
706    }
707
708    pub fn active_protocol(&self) -> Option<ProviderProtocol> {
709        self.active_protocol
710    }
711
712    pub fn active_provider_boundary_sha256(&self) -> Option<&str> {
713        self.active_provider_boundary_sha256.as_deref()
714    }
715
716    pub fn last_reset_reason(&self) -> Option<ProviderTranscriptResetReason> {
717        self.last_reset_reason
718    }
719
720    pub fn groups(&self) -> &[ProviderTranscriptGroup] {
721        &self.groups
722    }
723
724    /// Select the exact provider route for the next request. A family,
725    /// protocol, or same-family instance switch starts a new replay epoch,
726    /// making every older provider-minted item unreachable.
727    pub fn activate_route(
728        &mut self,
729        family: ProviderFamily,
730        protocol: ProviderProtocol,
731        provider_boundary_sha256: &str,
732    ) -> Result<bool, ProviderTranscriptError> {
733        if !protocol.supports_family(family) {
734            return Err(ProviderTranscriptError::FamilyProtocolMismatch);
735        }
736        if !is_sha256_hex(provider_boundary_sha256) {
737            return Err(ProviderTranscriptError::InvalidProviderBoundary);
738        }
739        if self.active_family == Some(family)
740            && self.active_protocol == Some(protocol)
741            && self.active_provider_boundary_sha256.as_deref() == Some(provider_boundary_sha256)
742        {
743            return Ok(false);
744        }
745        let is_switch = self.active_family.is_some()
746            || self.active_protocol.is_some()
747            || self.active_provider_boundary_sha256.is_some()
748            || !self.groups.is_empty();
749        if is_switch {
750            self.epoch = self.epoch.saturating_add(1);
751            self.next_sequence = 0;
752            self.last_reset_reason = Some(ProviderTranscriptResetReason::ProviderSwitch);
753        }
754        self.active_family = Some(family);
755        self.active_protocol = Some(protocol);
756        self.active_provider_boundary_sha256 = Some(provider_boundary_sha256.to_string());
757        self.state_revision = self.state_revision.saturating_add(1);
758        Ok(true)
759    }
760
761    /// Leave native replay when a session moves to a provider family Bamboo
762    /// cannot identify. Older items remain auditable but the new epoch has no
763    /// family capable of replaying them.
764    pub fn deactivate_route(&mut self) -> bool {
765        if self.active_family.is_none()
766            && self.active_protocol.is_none()
767            && self.active_provider_boundary_sha256.is_none()
768        {
769            return false;
770        }
771        self.epoch = self.epoch.saturating_add(1);
772        self.next_sequence = 0;
773        self.active_family = None;
774        self.active_protocol = None;
775        self.active_provider_boundary_sha256 = None;
776        self.last_reset_reason = Some(ProviderTranscriptResetReason::ProviderSwitch);
777        self.state_revision = self.state_revision.saturating_add(1);
778        true
779    }
780
781    pub fn invalidate(&mut self, reason: ProviderTranscriptResetReason) {
782        if self.is_empty() {
783            return;
784        }
785        self.epoch = self.epoch.saturating_add(1);
786        self.next_sequence = 0;
787        self.last_reset_reason = Some(reason);
788        self.state_revision = self.state_revision.saturating_add(1);
789    }
790
791    pub fn append_group(
792        &mut self,
793        anchor_message_id: impl Into<String>,
794        id_hint: Option<&str>,
795        items: Vec<ProviderTranscriptItem>,
796    ) -> Result<String, ProviderTranscriptError> {
797        let first = items.first().ok_or(ProviderTranscriptError::EmptyGroup)?;
798        let family = first.family;
799        let protocol = first.protocol;
800        let (provider_boundary_sha256, activates_unbound_route) = match (
801            self.active_family,
802            self.active_protocol,
803            self.active_provider_boundary_sha256.as_deref(),
804        ) {
805            (Some(active_family), Some(active_protocol), Some(boundary))
806                if active_family == family && active_protocol == protocol =>
807            {
808                (boundary.to_string(), false)
809            }
810            (Some(_), Some(_), Some(_)) => {
811                return Err(ProviderTranscriptError::InactiveProviderRoute)
812            }
813            (None, None, None) => (unbound_provider_boundary_sha256(), true),
814            _ => return Err(ProviderTranscriptError::InactiveProviderRoute),
815        };
816        // Validate the complete atomic group before mutating provider/epoch
817        // state. A malformed provider frame must fail closed without leaving a
818        // half-activated transcript lane behind.
819        let group = ProviderTranscriptGroup::new(
820            self.epoch,
821            self.next_sequence,
822            anchor_message_id.into(),
823            id_hint,
824            provider_boundary_sha256.clone(),
825            items,
826        )?;
827        if let Some(existing) = self.groups.iter().find(|current| current.id == group.id) {
828            if existing.epoch == group.epoch
829                && existing.anchor_message_id == group.anchor_message_id
830                && existing.family == group.family
831                && existing.protocol == group.protocol
832                && existing.provider_boundary_sha256 == group.provider_boundary_sha256
833                && existing.items == group.items
834            {
835                return Ok(group.id);
836            }
837            return Err(ProviderTranscriptError::DuplicateGroupId);
838        }
839        if activates_unbound_route {
840            self.active_family = Some(family);
841            self.active_protocol = Some(protocol);
842            self.active_provider_boundary_sha256 = Some(provider_boundary_sha256);
843            self.state_revision = self.state_revision.saturating_add(1);
844        }
845        let id = group.id.clone();
846        self.groups.push(group);
847        self.next_sequence = self.next_sequence.saturating_add(1);
848        self.state_revision = self.state_revision.saturating_add(1);
849        Ok(id)
850    }
851
852    /// Drop whole groups whose ordinary-message anchor no longer exists. No
853    /// item within a group is ever retained independently.
854    pub fn prune_dangling_groups(&mut self, live_message_ids: &HashSet<String>) -> usize {
855        let before = self.groups.len();
856        self.groups
857            .retain(|group| live_message_ids.contains(group.anchor_message_id()));
858        let removed = before.saturating_sub(self.groups.len());
859        if removed > 0 {
860            self.state_revision = self.state_revision.saturating_add(1);
861            self.last_reset_reason = Some(ProviderTranscriptResetReason::Rollback);
862        }
863        removed
864    }
865
866    pub fn replayable_groups(
867        &self,
868        family: ProviderFamily,
869        protocol: ProviderProtocol,
870        provider_boundary_sha256: &str,
871    ) -> Vec<&ProviderTranscriptGroup> {
872        if self.active_family != Some(family)
873            || self.active_protocol != Some(protocol)
874            || self.active_provider_boundary_sha256.as_deref() != Some(provider_boundary_sha256)
875            || !protocol.supports_family(family)
876        {
877            return Vec::new();
878        }
879        let mut groups = self
880            .groups
881            .iter()
882            .filter(|group| {
883                group.epoch == self.epoch
884                    && group.family == family
885                    && group.protocol == protocol
886                    && group.provider_boundary_sha256 == provider_boundary_sha256
887            })
888            .collect::<Vec<_>>();
889        groups.sort_by_key(|group| group.sequence);
890        groups
891    }
892
893    /// Append-safe merge used when a stale runner is reconciled with a durable
894    /// prefix. Epoch boundaries outrank append revisions; within one family and
895    /// epoch the higher revision wins. Missing compatible groups are retained,
896    /// while a same-epoch foreign group falls back to its ordinary message.
897    pub fn merge_durable_prefix(
898        &mut self,
899        durable: &ProviderTranscriptState,
900        ordered_message_ids: &[String],
901    ) -> usize {
902        let live = self.clone();
903        let durable_is_newer = durable.epoch > live.epoch
904            || (durable.epoch == live.epoch && durable.state_revision >= live.state_revision);
905        let mut merged = if durable_is_newer {
906            durable.clone()
907        } else {
908            live.clone()
909        };
910        let live_message_ids = ordered_message_ids.iter().cloned().collect::<HashSet<_>>();
911        let message_order = ordered_message_ids
912            .iter()
913            .enumerate()
914            .map(|(index, id)| (id.as_str(), index))
915            .collect::<HashMap<_, _>>();
916        let mut seen = merged
917            .groups
918            .iter()
919            .map(|group| group.id.clone())
920            .collect::<HashSet<_>>();
921        let mut added = 0usize;
922        for group in durable.groups.iter().chain(live.groups.iter()) {
923            if live_message_ids.contains(group.anchor_message_id()) && seen.insert(group.id.clone())
924            {
925                let group = group.clone();
926                if group.epoch == merged.epoch {
927                    if merged.active_family != Some(group.family)
928                        || merged.active_protocol != Some(group.protocol)
929                        || merged.active_provider_boundary_sha256.as_deref()
930                            != Some(group.provider_boundary_sha256())
931                    {
932                        // Concurrent provider branches at the same numeric epoch
933                        // cannot safely share raw state, even when both routes use
934                        // the same provider family. Keep the durable ordinary
935                        // message, but use its normalized fallback instead of
936                        // importing a foreign native group into the winning route.
937                        continue;
938                    }
939                } else if group.epoch > merged.epoch {
940                    // The winning boundary is authoritative. A future group from
941                    // a divergent snapshot cannot be rebased without changing
942                    // the provider transcript's meaning.
943                    continue;
944                }
945                merged.groups.push(group);
946                added = added.saturating_add(1);
947            }
948        }
949        merged.prune_dangling_groups(&live_message_ids);
950
951        // Durable messages are the authoritative prefix and live-only messages
952        // are appended after it. Rebuild the active epoch's sequence from that
953        // exact message chronology instead of inheriting whichever concurrent
954        // snapshot happened to win the revision tie.
955        let before_normalization = merged.groups.clone();
956        let mut historical = Vec::new();
957        let mut current = Vec::new();
958        for group in std::mem::take(&mut merged.groups) {
959            if group.epoch == merged.epoch {
960                current.push(group);
961            } else {
962                historical.push(group);
963            }
964        }
965        current.sort_by(|left, right| {
966            message_order
967                .get(left.anchor_message_id())
968                .copied()
969                .unwrap_or(usize::MAX)
970                .cmp(
971                    &message_order
972                        .get(right.anchor_message_id())
973                        .copied()
974                        .unwrap_or(usize::MAX),
975                )
976                .then_with(|| left.sequence.cmp(&right.sequence))
977                .then_with(|| left.id.cmp(&right.id))
978        });
979        for (sequence, group) in current.iter_mut().enumerate() {
980            group.sequence = sequence as u64;
981        }
982        merged.next_sequence = current.len() as u64;
983        historical.extend(current);
984        merged.groups = historical;
985        let normalized = merged.groups != before_normalization;
986        if added > 0 || normalized {
987            merged.state_revision = merged.state_revision.saturating_add(1);
988        }
989        *self = merged;
990        added
991    }
992
993    pub fn diagnostics(
994        &self,
995        family: ProviderFamily,
996        protocol: ProviderProtocol,
997        provider_boundary_sha256: &str,
998    ) -> ProviderTranscriptDiagnostics {
999        let groups = self.replayable_groups(family, protocol, provider_boundary_sha256);
1000        let item_count = groups.iter().map(|group| group.items.len()).sum();
1001        let bytes = serde_json::to_vec(&groups).unwrap_or_default();
1002        ProviderTranscriptDiagnostics {
1003            group_count: groups.len(),
1004            item_count,
1005            serialized_bytes: bytes.len(),
1006            sha256: hash_bytes(TRANSCRIPT_HASH_DOMAIN, &bytes),
1007        }
1008    }
1009}
1010
1011impl Session {
1012    pub fn activate_provider_transcript_route(
1013        &mut self,
1014        family: ProviderFamily,
1015        protocol: ProviderProtocol,
1016        provider_boundary_sha256: &str,
1017    ) -> Result<bool, ProviderTranscriptError> {
1018        let changed =
1019            self.provider_transcript
1020                .activate_route(family, protocol, provider_boundary_sha256)?;
1021        if changed {
1022            self.updated_at = chrono::Utc::now();
1023        }
1024        Ok(changed)
1025    }
1026
1027    pub fn deactivate_provider_transcript_route(&mut self) -> bool {
1028        let changed = self.provider_transcript.deactivate_route();
1029        if changed {
1030            self.updated_at = chrono::Utc::now();
1031        }
1032        changed
1033    }
1034
1035    pub fn append_provider_transcript_group(
1036        &mut self,
1037        anchor_message_id: impl Into<String>,
1038        id_hint: Option<&str>,
1039        items: Vec<ProviderTranscriptItem>,
1040    ) -> Result<String, ProviderTranscriptError> {
1041        let anchor_message_id = anchor_message_id.into();
1042        if !self
1043            .messages
1044            .iter()
1045            .any(|message| message.id == anchor_message_id)
1046        {
1047            return Err(ProviderTranscriptError::InvalidAnchor);
1048        }
1049        let id = self
1050            .provider_transcript
1051            .append_group(anchor_message_id, id_hint, items)?;
1052        self.updated_at = chrono::Utc::now();
1053        Ok(id)
1054    }
1055
1056    pub fn prune_provider_transcript(&mut self) -> usize {
1057        let live_message_ids = self
1058            .messages
1059            .iter()
1060            .map(|message| message.id.clone())
1061            .collect::<HashSet<_>>();
1062        self.provider_transcript
1063            .prune_dangling_groups(&live_message_ids)
1064    }
1065
1066    pub fn invalidate_provider_transcript(&mut self, reason: ProviderTranscriptResetReason) {
1067        self.provider_transcript.invalidate(reason);
1068        self.updated_at = chrono::Utc::now();
1069    }
1070
1071    pub fn merge_provider_transcript_from_durable(&mut self, durable: &Session) -> usize {
1072        let ordered_message_ids = self
1073            .messages
1074            .iter()
1075            .map(|message| message.id.clone())
1076            .collect::<Vec<_>>();
1077        self.provider_transcript
1078            .merge_durable_prefix(&durable.provider_transcript, &ordered_message_ids)
1079    }
1080}
1081
1082fn infer_and_validate_item(
1083    protocol: ProviderProtocol,
1084    origin: ProviderTranscriptOrigin,
1085    author: ProviderTranscriptAuthor,
1086    payload: &Value,
1087) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1088    let object = payload
1089        .as_object()
1090        .ok_or(ProviderTranscriptError::PayloadNotObject)?;
1091    let item_type = object
1092        .get("type")
1093        .and_then(Value::as_str)
1094        .ok_or(ProviderTranscriptError::InvalidItem("missing item type"))?;
1095
1096    match protocol {
1097        ProviderProtocol::OpenAiResponsesV1 => {
1098            validate_openai_item(item_type, origin, author, payload)
1099        }
1100        ProviderProtocol::AnthropicMessages2023_06_01 => {
1101            validate_anthropic_item(item_type, origin, author, payload)
1102        }
1103    }
1104}
1105
1106fn reject_unknown_fields(
1107    object: &serde_json::Map<String, Value>,
1108    allowed: &[&str],
1109    label: &'static str,
1110) -> Result<(), ProviderTranscriptError> {
1111    if object
1112        .keys()
1113        .any(|field| !allowed.contains(&field.as_str()))
1114    {
1115        return Err(ProviderTranscriptError::InvalidItem(label));
1116    }
1117    Ok(())
1118}
1119
1120fn validate_allowed_callers(value: Option<&Value>) -> bool {
1121    value.is_none_or(|value| {
1122        value.is_null()
1123            || value.as_array().is_some_and(|callers| {
1124                callers
1125                    .iter()
1126                    .all(|caller| matches!(caller.as_str(), Some("direct" | "programmatic")))
1127            })
1128    })
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132enum OpenAiFunctionDefinitionMode {
1133    /// A definition returned by OpenAI in a hosted tool-search output. The
1134    /// generated response model permits optional fields to be absent or null.
1135    ProviderOutput,
1136    /// A callable definition Bamboo creates for client search or a
1137    /// position-scoped `additional_tools` input item. OpenAI's request model
1138    /// requires both `parameters` and `strict` keys, while permitting either
1139    /// value to be null.
1140    HostInput,
1141    /// A function nested in a namespace. OpenAI's namespace member contract
1142    /// intentionally keeps parameters/strict optional and nullable for both
1143    /// request and response projections.
1144    NamespaceMember,
1145}
1146
1147fn validate_openai_function_definition(
1148    object: &serde_json::Map<String, Value>,
1149    mode: OpenAiFunctionDefinitionMode,
1150) -> Result<(), ProviderTranscriptError> {
1151    reject_unknown_fields(
1152        object,
1153        &[
1154            "type",
1155            "name",
1156            "parameters",
1157            "strict",
1158            "allowed_callers",
1159            "defer_loading",
1160            "description",
1161            "output_schema",
1162        ],
1163        "function definition fields",
1164    )?;
1165    let parameters_valid = match mode {
1166        OpenAiFunctionDefinitionMode::HostInput => object
1167            .get("parameters")
1168            .is_some_and(|parameters| parameters.is_null() || parameters.is_object()),
1169        OpenAiFunctionDefinitionMode::ProviderOutput
1170        | OpenAiFunctionDefinitionMode::NamespaceMember => object
1171            .get("parameters")
1172            .is_none_or(|parameters| parameters.is_null() || parameters.is_object()),
1173    };
1174    let nullable_optional = |value: Option<&Value>, predicate: fn(&Value) -> bool| {
1175        value.is_none_or(|value| value.is_null() || predicate(value))
1176    };
1177    if object.get("type").and_then(Value::as_str) != Some("function")
1178        || !object
1179            .get("name")
1180            .and_then(Value::as_str)
1181            .is_some_and(|name| !name.trim().is_empty())
1182        || !parameters_valid
1183        || match mode {
1184            OpenAiFunctionDefinitionMode::HostInput => !object
1185                .get("strict")
1186                .is_some_and(|strict| strict.is_null() || strict.is_boolean()),
1187            OpenAiFunctionDefinitionMode::ProviderOutput
1188            | OpenAiFunctionDefinitionMode::NamespaceMember => {
1189                !nullable_optional(object.get("strict"), Value::is_boolean)
1190            }
1191        }
1192        || !nullable_optional(object.get("defer_loading"), Value::is_boolean)
1193        || !nullable_optional(object.get("description"), Value::is_string)
1194        || !nullable_optional(object.get("output_schema"), Value::is_object)
1195        || !validate_allowed_callers(object.get("allowed_callers"))
1196    {
1197        return Err(ProviderTranscriptError::InvalidItem(
1198            "function definition shape",
1199        ));
1200    }
1201    Ok(())
1202}
1203
1204fn validate_openai_tool_definition(
1205    value: &Value,
1206    mode: OpenAiFunctionDefinitionMode,
1207) -> Result<(), ProviderTranscriptError> {
1208    let object = value
1209        .as_object()
1210        .ok_or(ProviderTranscriptError::InvalidItem("tool definition"))?;
1211    match object.get("type").and_then(Value::as_str) {
1212        Some("function") => validate_openai_function_definition(object, mode),
1213        Some("namespace") => {
1214            reject_unknown_fields(
1215                object,
1216                &["type", "name", "description", "tools"],
1217                "namespace definition fields",
1218            )?;
1219            let tools = object
1220                .get("tools")
1221                .and_then(Value::as_array)
1222                .ok_or(ProviderTranscriptError::InvalidItem("namespace tools"))?;
1223            if !object
1224                .get("name")
1225                .and_then(Value::as_str)
1226                .is_some_and(|name| !name.trim().is_empty())
1227                || !object
1228                    .get("description")
1229                    .and_then(Value::as_str)
1230                    .is_some_and(|description| !description.trim().is_empty())
1231            {
1232                return Err(ProviderTranscriptError::InvalidItem(
1233                    "namespace definition shape",
1234                ));
1235            }
1236            for tool in tools {
1237                let nested = tool
1238                    .as_object()
1239                    .ok_or(ProviderTranscriptError::InvalidItem(
1240                        "namespace tool definition",
1241                    ))?;
1242                // Bamboo currently lowers canonical callable capabilities as
1243                // functions. Other provider tool variants must be explicitly
1244                // implemented before they can enter durable replay state.
1245                validate_openai_function_definition(
1246                    nested,
1247                    OpenAiFunctionDefinitionMode::NamespaceMember,
1248                )?;
1249            }
1250            Ok(())
1251        }
1252        _ => Err(ProviderTranscriptError::InvalidItem(
1253            "unsupported loaded tool definition",
1254        )),
1255    }
1256}
1257
1258fn validate_openai_tool_definitions(
1259    value: Option<&Value>,
1260    mode: OpenAiFunctionDefinitionMode,
1261) -> Result<(), ProviderTranscriptError> {
1262    let tools = value
1263        .and_then(Value::as_array)
1264        .ok_or(ProviderTranscriptError::InvalidItem("loaded tools"))?;
1265    for tool in tools {
1266        validate_openai_tool_definition(tool, mode)?;
1267    }
1268    Ok(())
1269}
1270
1271fn is_nonempty_string(value: Option<&Value>) -> bool {
1272    value
1273        .and_then(Value::as_str)
1274        .is_some_and(|value| !value.trim().is_empty())
1275}
1276
1277fn is_optional_nullable_nonempty_string(value: Option<&Value>) -> bool {
1278    value.is_none_or(|value| {
1279        value.is_null() || value.as_str().is_some_and(|value| !value.trim().is_empty())
1280    })
1281}
1282
1283fn is_optional_nullable_item_status(value: Option<&Value>) -> bool {
1284    value.is_none_or(|value| value.is_null() || is_openai_item_status(Some(value)))
1285}
1286
1287fn is_openai_item_status(value: Option<&Value>) -> bool {
1288    matches!(
1289        value.and_then(Value::as_str),
1290        Some("in_progress" | "completed" | "incomplete")
1291    )
1292}
1293
1294fn validate_openai_agent(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1295    let Some(value) = value else {
1296        return Ok(());
1297    };
1298    if value.is_null() {
1299        return Ok(());
1300    }
1301    let agent = value
1302        .as_object()
1303        .ok_or(ProviderTranscriptError::InvalidItem("agent shape"))?;
1304    reject_unknown_fields(agent, &["agent_name"], "agent fields")?;
1305    if !is_nonempty_string(agent.get("agent_name")) {
1306        return Err(ProviderTranscriptError::InvalidItem("agent name"));
1307    }
1308    Ok(())
1309}
1310
1311fn validate_openai_annotation(value: &Value) -> Result<(), ProviderTranscriptError> {
1312    let annotation = value
1313        .as_object()
1314        .ok_or(ProviderTranscriptError::InvalidItem("output annotation"))?;
1315    let string = |field| annotation.get(field).is_some_and(Value::is_string);
1316    let index = |field| annotation.get(field).and_then(Value::as_u64).is_some();
1317    match annotation.get("type").and_then(Value::as_str) {
1318        Some("file_citation") => {
1319            reject_unknown_fields(
1320                annotation,
1321                &["type", "file_id", "filename", "index"],
1322                "file citation fields",
1323            )?;
1324            if !string("file_id") || !string("filename") || !index("index") {
1325                return Err(ProviderTranscriptError::InvalidItem("file citation shape"));
1326            }
1327        }
1328        Some("url_citation") => {
1329            reject_unknown_fields(
1330                annotation,
1331                &["type", "start_index", "end_index", "title", "url"],
1332                "url citation fields",
1333            )?;
1334            if !index("start_index") || !index("end_index") || !string("title") || !string("url") {
1335                return Err(ProviderTranscriptError::InvalidItem("url citation shape"));
1336            }
1337        }
1338        Some("container_file_citation") => {
1339            reject_unknown_fields(
1340                annotation,
1341                &[
1342                    "type",
1343                    "container_id",
1344                    "start_index",
1345                    "end_index",
1346                    "file_id",
1347                    "filename",
1348                ],
1349                "container citation fields",
1350            )?;
1351            if !string("container_id")
1352                || !index("start_index")
1353                || !index("end_index")
1354                || !string("file_id")
1355                || !string("filename")
1356            {
1357                return Err(ProviderTranscriptError::InvalidItem(
1358                    "container citation shape",
1359                ));
1360            }
1361        }
1362        Some("file_path") => {
1363            reject_unknown_fields(
1364                annotation,
1365                &["type", "file_id", "index"],
1366                "file path fields",
1367            )?;
1368            if !string("file_id") || !index("index") {
1369                return Err(ProviderTranscriptError::InvalidItem("file path shape"));
1370            }
1371        }
1372        _ => {
1373            return Err(ProviderTranscriptError::InvalidItem(
1374                "unsupported output annotation",
1375            ))
1376        }
1377    }
1378    Ok(())
1379}
1380
1381fn validate_openai_logprob_bytes(value: Option<&Value>) -> bool {
1382    value.and_then(Value::as_array).is_some_and(|bytes| {
1383        bytes
1384            .iter()
1385            .all(|byte| byte.as_u64().is_some_and(|byte| byte <= u8::MAX as u64))
1386    })
1387}
1388
1389fn validate_openai_logprobs(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1390    let Some(value) = value else {
1391        return Ok(());
1392    };
1393    if value.is_null() {
1394        return Ok(());
1395    }
1396    let logprobs = value
1397        .as_array()
1398        .ok_or(ProviderTranscriptError::InvalidItem("output logprobs"))?;
1399    for logprob in logprobs {
1400        let logprob = logprob
1401            .as_object()
1402            .ok_or(ProviderTranscriptError::InvalidItem("output logprob"))?;
1403        reject_unknown_fields(
1404            logprob,
1405            &["token", "bytes", "logprob", "top_logprobs"],
1406            "output logprob fields",
1407        )?;
1408        let top_logprobs = logprob
1409            .get("top_logprobs")
1410            .and_then(Value::as_array)
1411            .ok_or(ProviderTranscriptError::InvalidItem("top logprobs"))?;
1412        if !logprob.get("token").is_some_and(Value::is_string)
1413            || !validate_openai_logprob_bytes(logprob.get("bytes"))
1414            || !logprob.get("logprob").is_some_and(Value::is_number)
1415        {
1416            return Err(ProviderTranscriptError::InvalidItem("output logprob shape"));
1417        }
1418        for top in top_logprobs {
1419            let top = top
1420                .as_object()
1421                .ok_or(ProviderTranscriptError::InvalidItem("top logprob"))?;
1422            reject_unknown_fields(top, &["token", "bytes", "logprob"], "top logprob fields")?;
1423            if !top.get("token").is_some_and(Value::is_string)
1424                || !validate_openai_logprob_bytes(top.get("bytes"))
1425                || !top.get("logprob").is_some_and(Value::is_number)
1426            {
1427                return Err(ProviderTranscriptError::InvalidItem("top logprob shape"));
1428            }
1429        }
1430    }
1431    Ok(())
1432}
1433
1434fn validate_openai_message_content(value: &Value) -> Result<(), ProviderTranscriptError> {
1435    let content = value
1436        .as_array()
1437        .ok_or(ProviderTranscriptError::InvalidItem("message content"))?;
1438    for part in content {
1439        let part = part
1440            .as_object()
1441            .ok_or(ProviderTranscriptError::InvalidItem("message content part"))?;
1442        match part.get("type").and_then(Value::as_str) {
1443            Some("output_text") => {
1444                reject_unknown_fields(
1445                    part,
1446                    &["type", "text", "annotations", "logprobs"],
1447                    "output text fields",
1448                )?;
1449                let annotations = part
1450                    .get("annotations")
1451                    .and_then(Value::as_array)
1452                    .ok_or(ProviderTranscriptError::InvalidItem("output annotations"))?;
1453                if !part.get("text").is_some_and(Value::is_string) {
1454                    return Err(ProviderTranscriptError::InvalidItem("output text content"));
1455                }
1456                for annotation in annotations {
1457                    validate_openai_annotation(annotation)?;
1458                }
1459                validate_openai_logprobs(part.get("logprobs"))?;
1460            }
1461            Some("refusal") => {
1462                reject_unknown_fields(part, &["type", "refusal"], "refusal fields")?;
1463                if !part.get("refusal").is_some_and(Value::is_string) {
1464                    return Err(ProviderTranscriptError::InvalidItem("refusal content"));
1465                }
1466            }
1467            _ => {
1468                return Err(ProviderTranscriptError::InvalidItem(
1469                    "unsupported message content",
1470                ))
1471            }
1472        }
1473    }
1474    Ok(())
1475}
1476
1477fn validate_openai_reasoning_parts(
1478    value: Option<&Value>,
1479    kind: &'static str,
1480    required: bool,
1481) -> Result<(), ProviderTranscriptError> {
1482    let Some(value) = value else {
1483        return if required {
1484            Err(ProviderTranscriptError::InvalidItem("reasoning parts"))
1485        } else {
1486            Ok(())
1487        };
1488    };
1489    if value.is_null() && !required {
1490        return Ok(());
1491    }
1492    let parts = value
1493        .as_array()
1494        .ok_or(ProviderTranscriptError::InvalidItem("reasoning parts"))?;
1495    for part in parts {
1496        let part = part
1497            .as_object()
1498            .ok_or(ProviderTranscriptError::InvalidItem("reasoning part"))?;
1499        reject_unknown_fields(part, &["type", "text"], "reasoning part fields")?;
1500        if part.get("type").and_then(Value::as_str) != Some(kind)
1501            || !part.get("text").is_some_and(Value::is_string)
1502        {
1503            return Err(ProviderTranscriptError::InvalidItem("reasoning part shape"));
1504        }
1505    }
1506    Ok(())
1507}
1508
1509fn validate_openai_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1510    let Some(value) = value else {
1511        return Ok(());
1512    };
1513    if value.is_null() {
1514        return Ok(());
1515    }
1516    let caller = value
1517        .as_object()
1518        .ok_or(ProviderTranscriptError::InvalidItem("function caller"))?;
1519    match caller.get("type").and_then(Value::as_str) {
1520        Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1521        Some("program") => {
1522            reject_unknown_fields(caller, &["type", "caller_id"], "program caller fields")?;
1523            if !is_nonempty_string(caller.get("caller_id")) {
1524                return Err(ProviderTranscriptError::InvalidItem("program caller id"));
1525            }
1526            Ok(())
1527        }
1528        _ => Err(ProviderTranscriptError::InvalidItem(
1529            "unsupported function caller",
1530        )),
1531    }
1532}
1533
1534fn validate_openai_item(
1535    item_type: &str,
1536    origin: ProviderTranscriptOrigin,
1537    author: ProviderTranscriptAuthor,
1538    payload: &Value,
1539) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1540    let object = payload.as_object().expect("validated object");
1541    let require_nonempty_string = |field: &'static str| {
1542        object
1543            .get(field)
1544            .and_then(Value::as_str)
1545            .filter(|value| !value.trim().is_empty())
1546            .ok_or(ProviderTranscriptError::InvalidItem(field))
1547    };
1548    let execution = || {
1549        object
1550            .get("execution")
1551            .and_then(Value::as_str)
1552            .filter(|value| matches!(*value, "server" | "client"))
1553            .ok_or(ProviderTranscriptError::InvalidItem("execution"))
1554    };
1555
1556    match item_type {
1557        "message" if origin == ProviderTranscriptOrigin::Provider => {
1558            reject_unknown_fields(
1559                object,
1560                &["type", "id", "content", "role", "status", "agent", "phase"],
1561                "provider message fields",
1562            )?;
1563            if author != ProviderTranscriptAuthor::Model
1564                || !is_nonempty_string(object.get("id"))
1565                || object.get("role").and_then(Value::as_str) != Some("assistant")
1566                || !is_openai_item_status(object.get("status"))
1567                || object.get("phase").is_some_and(|phase| {
1568                    !phase.is_null()
1569                        && !matches!(phase.as_str(), Some("commentary" | "final_answer"))
1570                })
1571            {
1572                return Err(ProviderTranscriptError::InvalidItem(
1573                    "provider message shape",
1574                ));
1575            }
1576            validate_openai_agent(object.get("agent"))?;
1577            validate_openai_message_content(object.get("content").ok_or(
1578                ProviderTranscriptError::InvalidItem("provider message content"),
1579            )?)?;
1580            Ok(ProviderTranscriptItemKind::OpenAiMessage)
1581        }
1582        "reasoning" if origin == ProviderTranscriptOrigin::Provider => {
1583            reject_unknown_fields(
1584                object,
1585                &[
1586                    "type",
1587                    "id",
1588                    "summary",
1589                    "agent",
1590                    "content",
1591                    "encrypted_content",
1592                    "status",
1593                ],
1594                "reasoning fields",
1595            )?;
1596            if author != ProviderTranscriptAuthor::Model
1597                || !is_nonempty_string(object.get("id"))
1598                || !object
1599                    .get("encrypted_content")
1600                    .is_none_or(|content| content.is_null() || content.is_string())
1601                || !is_optional_nullable_item_status(object.get("status"))
1602            {
1603                return Err(ProviderTranscriptError::InvalidItem("reasoning shape"));
1604            }
1605            validate_openai_agent(object.get("agent"))?;
1606            validate_openai_reasoning_parts(object.get("summary"), "summary_text", true)?;
1607            validate_openai_reasoning_parts(object.get("content"), "reasoning_text", false)?;
1608            Ok(ProviderTranscriptItemKind::OpenAiReasoning)
1609        }
1610        "function_call" if origin == ProviderTranscriptOrigin::Provider => {
1611            reject_unknown_fields(
1612                object,
1613                &[
1614                    "type",
1615                    "id",
1616                    "arguments",
1617                    "call_id",
1618                    "name",
1619                    "agent",
1620                    "caller",
1621                    "namespace",
1622                    "status",
1623                    "created_by",
1624                ],
1625                "function call fields",
1626            )?;
1627            require_nonempty_string("name")?;
1628            require_nonempty_string("call_id")?;
1629            let arguments = object.get("arguments").and_then(Value::as_str);
1630            if author != ProviderTranscriptAuthor::Model
1631                || !is_optional_nullable_nonempty_string(object.get("id"))
1632                || !is_optional_nullable_nonempty_string(object.get("namespace"))
1633                || !is_optional_nullable_item_status(object.get("status"))
1634                || !is_optional_nullable_nonempty_string(object.get("created_by"))
1635                || arguments.is_none()
1636                || !arguments.is_some_and(|arguments| {
1637                    serde_json::from_str::<Value>(arguments)
1638                        .ok()
1639                        .is_some_and(|value| value.is_object())
1640                })
1641            {
1642                return Err(ProviderTranscriptError::InvalidItem("function call shape"));
1643            }
1644            validate_openai_agent(object.get("agent"))?;
1645            validate_openai_caller(object.get("caller"))?;
1646            Ok(ProviderTranscriptItemKind::OpenAiFunctionCall)
1647        }
1648        "tool_search_call" if origin == ProviderTranscriptOrigin::Provider => {
1649            reject_unknown_fields(
1650                object,
1651                &[
1652                    "type",
1653                    "id",
1654                    "arguments",
1655                    "call_id",
1656                    "execution",
1657                    "status",
1658                    "agent",
1659                    "created_by",
1660                ],
1661                "tool search call fields",
1662            )?;
1663            let execution = execution()?;
1664            require_nonempty_string("id")?;
1665            let call_id_valid = if execution == "client" {
1666                is_nonempty_string(object.get("call_id"))
1667            } else {
1668                is_optional_nullable_nonempty_string(object.get("call_id"))
1669            };
1670            if author != ProviderTranscriptAuthor::Model
1671                || object.get("status").and_then(Value::as_str) != Some("completed")
1672                || !object.get("arguments").is_some_and(Value::is_object)
1673                || !call_id_valid
1674                || !is_optional_nullable_nonempty_string(object.get("created_by"))
1675            {
1676                return Err(ProviderTranscriptError::InvalidItem(
1677                    "tool search call shape",
1678                ));
1679            }
1680            validate_openai_agent(object.get("agent"))?;
1681            Ok(ProviderTranscriptItemKind::OpenAiToolSearchCall)
1682        }
1683        "tool_search_output" => {
1684            let execution = execution()?;
1685            if author != ProviderTranscriptAuthor::ToolResult
1686                || object.get("status").and_then(Value::as_str) != Some("completed")
1687            {
1688                return Err(ProviderTranscriptError::InvalidItem(
1689                    "tool search output shape",
1690                ));
1691            }
1692            match (execution, origin) {
1693                ("server", ProviderTranscriptOrigin::Provider) => {
1694                    reject_unknown_fields(
1695                        object,
1696                        &[
1697                            "type",
1698                            "id",
1699                            "call_id",
1700                            "execution",
1701                            "status",
1702                            "tools",
1703                            "agent",
1704                            "created_by",
1705                        ],
1706                        "server tool search output fields",
1707                    )?;
1708                    require_nonempty_string("id")?;
1709                    if !is_optional_nullable_nonempty_string(object.get("call_id"))
1710                        || !is_optional_nullable_nonempty_string(object.get("created_by"))
1711                    {
1712                        return Err(ProviderTranscriptError::InvalidItem(
1713                            "tool search output metadata",
1714                        ));
1715                    }
1716                    validate_openai_agent(object.get("agent"))?;
1717                    validate_openai_tool_definitions(
1718                        object.get("tools"),
1719                        OpenAiFunctionDefinitionMode::ProviderOutput,
1720                    )?;
1721                }
1722                ("client", ProviderTranscriptOrigin::HostToolSearch) => {
1723                    reject_unknown_fields(
1724                        object,
1725                        &["type", "call_id", "execution", "status", "tools"],
1726                        "client tool search output fields",
1727                    )?;
1728                    require_nonempty_string("call_id")?;
1729                    validate_openai_tool_definitions(
1730                        object.get("tools"),
1731                        OpenAiFunctionDefinitionMode::HostInput,
1732                    )?;
1733                }
1734                _ => {
1735                    return Err(ProviderTranscriptError::InvalidItem(
1736                        "tool search output origin",
1737                    ))
1738                }
1739            }
1740            Ok(ProviderTranscriptItemKind::OpenAiToolSearchOutput)
1741        }
1742        "additional_tools" if origin == ProviderTranscriptOrigin::DeveloperContext => {
1743            reject_unknown_fields(
1744                object,
1745                &["type", "role", "tools"],
1746                "additional tools fields",
1747            )?;
1748            if author != ProviderTranscriptAuthor::Host
1749                || object.get("role").and_then(Value::as_str) != Some("developer")
1750            {
1751                return Err(ProviderTranscriptError::InvalidItem(
1752                    "additional tools shape",
1753                ));
1754            }
1755            validate_openai_tool_definitions(
1756                object.get("tools"),
1757                OpenAiFunctionDefinitionMode::HostInput,
1758            )?;
1759            Ok(ProviderTranscriptItemKind::OpenAiAdditionalTools)
1760        }
1761        _ => Err(ProviderTranscriptError::UnsupportedItemType),
1762    }
1763}
1764
1765fn validate_anthropic_caller(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1766    let Some(value) = value else {
1767        return Ok(());
1768    };
1769    let caller = value
1770        .as_object()
1771        .ok_or(ProviderTranscriptError::InvalidItem("Anthropic caller"))?;
1772    match caller.get("type").and_then(Value::as_str) {
1773        Some("direct") => reject_unknown_fields(caller, &["type"], "direct caller fields"),
1774        Some("code_execution_20250825" | "code_execution_20260120" | "code_execution_20260521") => {
1775            reject_unknown_fields(caller, &["type", "tool_id"], "server caller fields")?;
1776            if !is_nonempty_string(caller.get("tool_id")) {
1777                return Err(ProviderTranscriptError::InvalidItem(
1778                    "server caller tool id",
1779                ));
1780            }
1781            Ok(())
1782        }
1783        _ => Err(ProviderTranscriptError::InvalidItem(
1784            "unsupported Anthropic caller",
1785        )),
1786    }
1787}
1788
1789fn is_string_or_null(value: Option<&Value>) -> bool {
1790    value.is_some_and(|value| value.is_string() || value.is_null())
1791}
1792
1793fn validate_anthropic_text_citation(value: &Value) -> Result<(), ProviderTranscriptError> {
1794    let citation = value
1795        .as_object()
1796        .ok_or(ProviderTranscriptError::InvalidItem("text citation"))?;
1797    let string = |field| citation.get(field).is_some_and(Value::is_string);
1798    let index = |field| citation.get(field).and_then(Value::as_u64).is_some();
1799    match citation.get("type").and_then(Value::as_str) {
1800        Some("char_location") => {
1801            reject_unknown_fields(
1802                citation,
1803                &[
1804                    "type",
1805                    "cited_text",
1806                    "document_index",
1807                    "document_title",
1808                    "start_char_index",
1809                    "end_char_index",
1810                    "file_id",
1811                ],
1812                "char citation fields",
1813            )?;
1814            if !string("cited_text")
1815                || !index("document_index")
1816                || !is_string_or_null(citation.get("document_title"))
1817                || !index("start_char_index")
1818                || !index("end_char_index")
1819                || !is_string_or_null(citation.get("file_id"))
1820            {
1821                return Err(ProviderTranscriptError::InvalidItem("char citation shape"));
1822            }
1823        }
1824        Some("page_location") => {
1825            reject_unknown_fields(
1826                citation,
1827                &[
1828                    "type",
1829                    "cited_text",
1830                    "document_index",
1831                    "document_title",
1832                    "start_page_number",
1833                    "end_page_number",
1834                    "file_id",
1835                ],
1836                "page citation fields",
1837            )?;
1838            if !string("cited_text")
1839                || !index("document_index")
1840                || !is_string_or_null(citation.get("document_title"))
1841                || !index("start_page_number")
1842                || !index("end_page_number")
1843                || !is_string_or_null(citation.get("file_id"))
1844            {
1845                return Err(ProviderTranscriptError::InvalidItem("page citation shape"));
1846            }
1847        }
1848        Some("content_block_location") => {
1849            reject_unknown_fields(
1850                citation,
1851                &[
1852                    "type",
1853                    "cited_text",
1854                    "document_index",
1855                    "document_title",
1856                    "start_block_index",
1857                    "end_block_index",
1858                    "file_id",
1859                ],
1860                "content citation fields",
1861            )?;
1862            if !string("cited_text")
1863                || !index("document_index")
1864                || !is_string_or_null(citation.get("document_title"))
1865                || !index("start_block_index")
1866                || !index("end_block_index")
1867                || !is_string_or_null(citation.get("file_id"))
1868            {
1869                return Err(ProviderTranscriptError::InvalidItem(
1870                    "content citation shape",
1871                ));
1872            }
1873        }
1874        Some("web_search_result_location") => {
1875            reject_unknown_fields(
1876                citation,
1877                &["type", "cited_text", "encrypted_index", "title", "url"],
1878                "web citation fields",
1879            )?;
1880            if !string("cited_text")
1881                || !string("encrypted_index")
1882                || !is_string_or_null(citation.get("title"))
1883                || !string("url")
1884            {
1885                return Err(ProviderTranscriptError::InvalidItem("web citation shape"));
1886            }
1887        }
1888        Some("search_result_location") => {
1889            reject_unknown_fields(
1890                citation,
1891                &[
1892                    "type",
1893                    "cited_text",
1894                    "start_block_index",
1895                    "end_block_index",
1896                    "search_result_index",
1897                    "source",
1898                    "title",
1899                ],
1900                "search citation fields",
1901            )?;
1902            if !string("cited_text")
1903                || !index("start_block_index")
1904                || !index("end_block_index")
1905                || !index("search_result_index")
1906                || !string("source")
1907                || !is_string_or_null(citation.get("title"))
1908            {
1909                return Err(ProviderTranscriptError::InvalidItem(
1910                    "search citation shape",
1911                ));
1912            }
1913        }
1914        _ => {
1915            return Err(ProviderTranscriptError::InvalidItem(
1916                "unsupported text citation",
1917            ))
1918        }
1919    }
1920    Ok(())
1921}
1922
1923fn validate_anthropic_text_citations(value: Option<&Value>) -> Result<(), ProviderTranscriptError> {
1924    let Some(value) = value else {
1925        return Ok(());
1926    };
1927    if value.is_null() {
1928        return Ok(());
1929    }
1930    let citations = value
1931        .as_array()
1932        .ok_or(ProviderTranscriptError::InvalidItem("text citations"))?;
1933    for citation in citations {
1934        validate_anthropic_text_citation(citation)?;
1935    }
1936    Ok(())
1937}
1938
1939fn validate_anthropic_tool_search_input(
1940    name: &str,
1941    value: Option<&Value>,
1942) -> Result<(), ProviderTranscriptError> {
1943    let input = value
1944        .and_then(Value::as_object)
1945        .ok_or(ProviderTranscriptError::InvalidItem(
1946            "tool-search server input",
1947        ))?;
1948    let (query_field, maximum_chars) = match name {
1949        "tool_search_tool_regex" => ("pattern", 200usize),
1950        "tool_search_tool_bm25" => ("query", 500usize),
1951        _ => {
1952            return Err(ProviderTranscriptError::InvalidItem(
1953                "tool-search server name",
1954            ))
1955        }
1956    };
1957    reject_unknown_fields(
1958        input,
1959        &[query_field, "limit"],
1960        "tool-search server input fields",
1961    )?;
1962    let query = input.get(query_field).and_then(Value::as_str).ok_or(
1963        ProviderTranscriptError::InvalidItem("tool-search server query"),
1964    )?;
1965    if query.chars().count() > maximum_chars {
1966        return Err(ProviderTranscriptError::InvalidItem(
1967            "tool-search server query length",
1968        ));
1969    }
1970    if input.get("limit").is_some_and(|limit| {
1971        !limit
1972            .as_u64()
1973            .is_some_and(|limit| (1..=10_000).contains(&limit))
1974    }) {
1975        return Err(ProviderTranscriptError::InvalidItem(
1976            "tool-search server result limit",
1977        ));
1978    }
1979    Ok(())
1980}
1981
1982fn validate_anthropic_item(
1983    item_type: &str,
1984    origin: ProviderTranscriptOrigin,
1985    author: ProviderTranscriptAuthor,
1986    payload: &Value,
1987) -> Result<ProviderTranscriptItemKind, ProviderTranscriptError> {
1988    let object = payload.as_object().expect("validated object");
1989    let nonempty = |field: &'static str| {
1990        object
1991            .get(field)
1992            .and_then(Value::as_str)
1993            .filter(|value| !value.trim().is_empty())
1994            .ok_or(ProviderTranscriptError::InvalidItem(field))
1995    };
1996    match item_type {
1997        "text" if origin == ProviderTranscriptOrigin::Provider => {
1998            reject_unknown_fields(object, &["type", "text", "citations"], "text block fields")?;
1999            if author != ProviderTranscriptAuthor::Model
2000                || !object.get("text").is_some_and(Value::is_string)
2001            {
2002                return Err(ProviderTranscriptError::InvalidItem("text block"));
2003            }
2004            validate_anthropic_text_citations(object.get("citations"))?;
2005            Ok(ProviderTranscriptItemKind::AnthropicText)
2006        }
2007        "thinking" if origin == ProviderTranscriptOrigin::Provider => {
2008            reject_unknown_fields(
2009                object,
2010                &["type", "thinking", "signature"],
2011                "thinking block fields",
2012            )?;
2013            if author != ProviderTranscriptAuthor::Model
2014                || !object.get("thinking").is_some_and(Value::is_string)
2015                || !object
2016                    .get("signature")
2017                    .and_then(Value::as_str)
2018                    .is_some_and(|signature| !signature.trim().is_empty())
2019            {
2020                return Err(ProviderTranscriptError::InvalidItem("thinking block"));
2021            }
2022            Ok(ProviderTranscriptItemKind::AnthropicThinking)
2023        }
2024        "redacted_thinking" if origin == ProviderTranscriptOrigin::Provider => {
2025            reject_unknown_fields(object, &["type", "data"], "redacted thinking fields")?;
2026            if author != ProviderTranscriptAuthor::Model
2027                || !object.get("data").is_some_and(Value::is_string)
2028            {
2029                return Err(ProviderTranscriptError::InvalidItem(
2030                    "redacted thinking block",
2031                ));
2032            }
2033            Ok(ProviderTranscriptItemKind::AnthropicRedactedThinking)
2034        }
2035        "server_tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2036            reject_unknown_fields(
2037                object,
2038                &["type", "id", "name", "input", "caller"],
2039                "server tool use fields",
2040            )?;
2041            nonempty("id")?;
2042            let name = nonempty("name")?;
2043            if author != ProviderTranscriptAuthor::Model
2044                || !matches!(name, "tool_search_tool_regex" | "tool_search_tool_bm25")
2045            {
2046                return Err(ProviderTranscriptError::InvalidItem(
2047                    "tool-search server_tool_use block",
2048                ));
2049            }
2050            validate_anthropic_tool_search_input(name, object.get("input"))?;
2051            validate_anthropic_caller(object.get("caller"))?;
2052            Ok(ProviderTranscriptItemKind::AnthropicServerToolUse)
2053        }
2054        "tool_search_tool_result" if origin == ProviderTranscriptOrigin::Provider => {
2055            reject_unknown_fields(
2056                object,
2057                &["type", "tool_use_id", "content"],
2058                "tool search result fields",
2059            )?;
2060            nonempty("tool_use_id")?;
2061            if author != ProviderTranscriptAuthor::ToolResult {
2062                return Err(ProviderTranscriptError::InvalidItem(
2063                    "tool search result author",
2064                ));
2065            }
2066            validate_anthropic_search_result_content(object.get("content"))?;
2067            Ok(ProviderTranscriptItemKind::AnthropicToolSearchToolResult)
2068        }
2069        "tool_use" if origin == ProviderTranscriptOrigin::Provider => {
2070            reject_unknown_fields(
2071                object,
2072                &["type", "id", "name", "input", "caller", "toolset_name"],
2073                "tool use fields",
2074            )?;
2075            nonempty("id")?;
2076            nonempty("name")?;
2077            if author != ProviderTranscriptAuthor::Model
2078                || !object.get("input").is_some_and(Value::is_object)
2079                || object.get("toolset_name").is_some_and(|toolset| {
2080                    !(toolset.is_null()
2081                        || toolset.as_str().is_some_and(|name| !name.trim().is_empty()))
2082                })
2083            {
2084                return Err(ProviderTranscriptError::InvalidItem("tool_use block"));
2085            }
2086            validate_anthropic_caller(object.get("caller"))?;
2087            Ok(ProviderTranscriptItemKind::AnthropicToolUse)
2088        }
2089        "tool_result" if origin == ProviderTranscriptOrigin::HostToolSearch => {
2090            reject_unknown_fields(
2091                object,
2092                &["type", "tool_use_id", "content", "is_error"],
2093                "custom tool result fields",
2094            )?;
2095            nonempty("tool_use_id")?;
2096            let Some(content) = object.get("content").and_then(Value::as_array) else {
2097                return Err(ProviderTranscriptError::InvalidItem(
2098                    "custom tool search result content",
2099                ));
2100            };
2101            if author != ProviderTranscriptAuthor::ToolResult
2102                || object
2103                    .get("is_error")
2104                    .is_some_and(|is_error| is_error.as_bool().is_none_or(|is_error| is_error))
2105                || content.iter().any(|reference| {
2106                    let Some(reference) = reference.as_object() else {
2107                        return true;
2108                    };
2109                    reference.len() != 2
2110                        || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2111                        || !reference
2112                            .get("tool_name")
2113                            .and_then(Value::as_str)
2114                            .is_some_and(|name| !name.trim().is_empty())
2115                })
2116            {
2117                return Err(ProviderTranscriptError::InvalidItem(
2118                    "custom tool references",
2119                ));
2120            }
2121            Ok(ProviderTranscriptItemKind::AnthropicToolResult)
2122        }
2123        _ => Err(ProviderTranscriptError::UnsupportedItemType),
2124    }
2125}
2126
2127fn validate_anthropic_search_result_content(
2128    content: Option<&Value>,
2129) -> Result<(), ProviderTranscriptError> {
2130    let Some(content) = content.and_then(Value::as_object) else {
2131        return Err(ProviderTranscriptError::InvalidItem(
2132            "tool search result content",
2133        ));
2134    };
2135    match content.get("type").and_then(Value::as_str) {
2136        Some("tool_search_tool_search_result") => {
2137            reject_unknown_fields(
2138                content,
2139                &["type", "tool_references"],
2140                "tool reference result fields",
2141            )?;
2142            let Some(references) = content.get("tool_references").and_then(Value::as_array) else {
2143                return Err(ProviderTranscriptError::InvalidItem("tool references"));
2144            };
2145            if references.iter().any(|reference| {
2146                let Some(reference) = reference.as_object() else {
2147                    return true;
2148                };
2149                reference.len() != 2
2150                    || reference.get("type").and_then(Value::as_str) != Some("tool_reference")
2151                    || !reference
2152                        .get("tool_name")
2153                        .and_then(Value::as_str)
2154                        .is_some_and(|name| !name.trim().is_empty())
2155            }) {
2156                return Err(ProviderTranscriptError::InvalidItem("tool reference"));
2157            }
2158            Ok(())
2159        }
2160        Some("tool_search_tool_result_error") => {
2161            reject_unknown_fields(
2162                content,
2163                &["type", "error_code", "error_message"],
2164                "tool search error fields",
2165            )?;
2166            if !matches!(
2167                content.get("error_code").and_then(Value::as_str),
2168                Some(
2169                    "invalid_tool_input"
2170                        | "unavailable"
2171                        | "too_many_requests"
2172                        | "execution_time_exceeded"
2173                )
2174            ) || !is_string_or_null(content.get("error_message"))
2175            {
2176                return Err(ProviderTranscriptError::InvalidItem(
2177                    "tool search error shape",
2178                ));
2179            }
2180            Ok(())
2181        }
2182        _ => Err(ProviderTranscriptError::InvalidItem(
2183            "tool search result content type",
2184        )),
2185    }
2186}
2187
2188fn validated_group_identity(
2189    items: &[ProviderTranscriptItem],
2190) -> Result<(ProviderFamily, ProviderProtocol), ProviderTranscriptError> {
2191    let Some(first) = items.first() else {
2192        return Err(ProviderTranscriptError::EmptyGroup);
2193    };
2194    let family = first.family;
2195    let protocol = first.protocol;
2196    if items
2197        .iter()
2198        .any(|item| item.family != family || item.protocol != protocol)
2199    {
2200        return Err(ProviderTranscriptError::MixedProviderGroup);
2201    }
2202    if !items.iter().any(|item| item.kind.is_discovery()) {
2203        return Err(ProviderTranscriptError::MissingDiscoveryItem);
2204    }
2205    validate_group_order(protocol, items)?;
2206    Ok((family, protocol))
2207}
2208
2209fn validate_group_order(
2210    protocol: ProviderProtocol,
2211    items: &[ProviderTranscriptItem],
2212) -> Result<(), ProviderTranscriptError> {
2213    match protocol {
2214        ProviderProtocol::OpenAiResponsesV1 => {
2215            let mut seen_item_ids = HashSet::<&str>::new();
2216            let mut seen_search_call_ids = HashSet::<&str>::new();
2217            let mut seen_search_outputs = HashSet::<(&str, &str)>::new();
2218            let mut pending_provider_calls = HashSet::<&str>::new();
2219            let mut pending_client_calls = HashSet::<&str>::new();
2220            let mut pending_unkeyed_provider_calls = 0usize;
2221            let mut has_client_search_call = false;
2222            let mut has_client_search_output = false;
2223            let mut loaded_at = HashMap::<String, usize>::new();
2224            let mut function_calls = Vec::<(usize, &str)>::new();
2225            for (index, item) in items.iter().enumerate() {
2226                if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2227                    if !seen_item_ids.insert(id) {
2228                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2229                    }
2230                }
2231                match item.kind {
2232                    ProviderTranscriptItemKind::OpenAiToolSearchCall => {
2233                        let execution = item
2234                            .payload
2235                            .get("execution")
2236                            .and_then(Value::as_str)
2237                            .unwrap_or_default();
2238                        let call_id = item
2239                            .payload
2240                            .get("call_id")
2241                            .and_then(Value::as_str)
2242                            .unwrap_or_default();
2243                        if call_id.is_empty() {
2244                            if execution == "server" {
2245                                pending_unkeyed_provider_calls =
2246                                    pending_unkeyed_provider_calls.saturating_add(1);
2247                            } else {
2248                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2249                            }
2250                        } else {
2251                            if !seen_search_call_ids.insert(call_id) {
2252                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2253                            }
2254                            if execution == "server" {
2255                                pending_provider_calls.insert(call_id);
2256                            } else {
2257                                pending_client_calls.insert(call_id);
2258                            }
2259                        }
2260                        has_client_search_call |= execution == "client";
2261                    }
2262                    ProviderTranscriptItemKind::OpenAiToolSearchOutput => {
2263                        let execution = item
2264                            .payload
2265                            .get("execution")
2266                            .and_then(Value::as_str)
2267                            .unwrap_or_default();
2268                        let call_id = item
2269                            .payload
2270                            .get("call_id")
2271                            .and_then(Value::as_str)
2272                            .unwrap_or_default();
2273                        if !call_id.is_empty() && !seen_search_outputs.insert((execution, call_id))
2274                        {
2275                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2276                        }
2277                        let matched_pending = if call_id.is_empty() {
2278                            if execution != "server" || pending_unkeyed_provider_calls == 0 {
2279                                false
2280                            } else {
2281                                pending_unkeyed_provider_calls =
2282                                    pending_unkeyed_provider_calls.saturating_sub(1);
2283                                true
2284                            }
2285                        } else {
2286                            let pending = if execution == "server" {
2287                                &mut pending_provider_calls
2288                            } else {
2289                                &mut pending_client_calls
2290                            };
2291                            pending.remove(call_id)
2292                        };
2293                        has_client_search_output |= execution == "client";
2294                        if !matched_pending
2295                            && (execution == "server"
2296                                || item.origin != ProviderTranscriptOrigin::HostToolSearch)
2297                        {
2298                            // A client output can be appended in a later host
2299                            // group after the original model call was committed.
2300                            // Hosted/server outputs must always complete a call
2301                            // in this same provider-owned atomic response.
2302                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2303                        }
2304                        for name in openai_loaded_tool_names(&item.payload) {
2305                            loaded_at.entry(name).or_insert(index);
2306                        }
2307                    }
2308                    ProviderTranscriptItemKind::OpenAiFunctionCall => {
2309                        let name = item
2310                            .payload
2311                            .get("name")
2312                            .and_then(Value::as_str)
2313                            .unwrap_or_default();
2314                        function_calls.push((index, name));
2315                    }
2316                    _ => {}
2317                }
2318            }
2319            if !pending_provider_calls.is_empty() || pending_unkeyed_provider_calls != 0 {
2320                return Err(ProviderTranscriptError::InvalidGroupOrder);
2321            }
2322            // Client execution is a suspension boundary: the provider-owned
2323            // response stops at tool_search_call, and the host resumes it in a
2324            // later standalone HostToolSearch output group. Express this as an
2325            // order-independent group invariant so reversed malformed output
2326            // cannot bypass a forward-only state check.
2327            if has_client_search_call && (has_client_search_output || !function_calls.is_empty()) {
2328                return Err(ProviderTranscriptError::InvalidGroupOrder);
2329            }
2330            let mut matched_loaded_call = false;
2331            for (index, name) in &function_calls {
2332                if let Some(output_index) = loaded_at.get(*name) {
2333                    if index <= output_index {
2334                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2335                    }
2336                    matched_loaded_call = true;
2337                }
2338            }
2339            if !loaded_at.is_empty() && !function_calls.is_empty() && !matched_loaded_call {
2340                return Err(ProviderTranscriptError::InvalidGroupOrder);
2341            }
2342        }
2343        ProviderProtocol::AnthropicMessages2023_06_01 => {
2344            let mut server_ids = HashSet::new();
2345            let mut all_tool_use_ids = HashSet::new();
2346            let mut completed_server_ids = HashSet::new();
2347            let mut referenced_at = HashMap::<String, usize>::new();
2348            let mut tool_uses = Vec::<(usize, &str)>::new();
2349            let mut thinking_blocks = 0usize;
2350            for (index, item) in items.iter().enumerate() {
2351                match item.kind {
2352                    ProviderTranscriptItemKind::AnthropicThinking
2353                    | ProviderTranscriptItemKind::AnthropicRedactedThinking => {
2354                        thinking_blocks = thinking_blocks.saturating_add(1);
2355                        if index != 0 || thinking_blocks != 1 {
2356                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2357                        }
2358                    }
2359                    ProviderTranscriptItemKind::AnthropicServerToolUse => {
2360                        if let Some(id) = item.payload.get("id").and_then(Value::as_str) {
2361                            if !server_ids.insert(id.to_string())
2362                                || !all_tool_use_ids.insert(id.to_string())
2363                            {
2364                                return Err(ProviderTranscriptError::InvalidGroupOrder);
2365                            }
2366                        }
2367                    }
2368                    ProviderTranscriptItemKind::AnthropicToolSearchToolResult => {
2369                        let tool_use_id = item
2370                            .payload
2371                            .get("tool_use_id")
2372                            .and_then(Value::as_str)
2373                            .unwrap_or_default();
2374                        if !server_ids.contains(tool_use_id)
2375                            || !completed_server_ids.insert(tool_use_id.to_string())
2376                        {
2377                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2378                        }
2379                        if let Some(references) = item
2380                            .payload
2381                            .get("content")
2382                            .and_then(|content| content.get("tool_references"))
2383                            .and_then(Value::as_array)
2384                        {
2385                            for reference in references {
2386                                if let Some(name) =
2387                                    reference.get("tool_name").and_then(Value::as_str)
2388                                {
2389                                    referenced_at.entry(name.to_string()).or_insert(index);
2390                                }
2391                            }
2392                        }
2393                    }
2394                    ProviderTranscriptItemKind::AnthropicToolUse => {
2395                        let id = item
2396                            .payload
2397                            .get("id")
2398                            .and_then(Value::as_str)
2399                            .unwrap_or_default();
2400                        if !all_tool_use_ids.insert(id.to_string()) {
2401                            return Err(ProviderTranscriptError::InvalidGroupOrder);
2402                        }
2403                        let name = item
2404                            .payload
2405                            .get("name")
2406                            .and_then(Value::as_str)
2407                            .unwrap_or_default();
2408                        tool_uses.push((index, name));
2409                    }
2410                    _ => {}
2411                }
2412            }
2413            if completed_server_ids.len() != server_ids.len() {
2414                return Err(ProviderTranscriptError::InvalidGroupOrder);
2415            }
2416            let mut matched_reference_use = false;
2417            for (index, name) in &tool_uses {
2418                if let Some(result_index) = referenced_at.get(*name) {
2419                    if index <= result_index {
2420                        return Err(ProviderTranscriptError::InvalidGroupOrder);
2421                    }
2422                    matched_reference_use = true;
2423                }
2424            }
2425            if !referenced_at.is_empty() && !tool_uses.is_empty() && !matched_reference_use {
2426                return Err(ProviderTranscriptError::InvalidGroupOrder);
2427            }
2428        }
2429    }
2430    Ok(())
2431}
2432
2433fn openai_loaded_tool_names(payload: &Value) -> Vec<String> {
2434    let Some(tools) = payload.get("tools").and_then(Value::as_array) else {
2435        return Vec::new();
2436    };
2437    let mut names = Vec::new();
2438    for tool in tools {
2439        match tool.get("type").and_then(Value::as_str) {
2440            Some("function") => {
2441                if let Some(name) = tool.get("name").and_then(Value::as_str) {
2442                    names.push(name.to_string());
2443                }
2444            }
2445            Some("namespace") => {
2446                let namespace = tool.get("name").and_then(Value::as_str).unwrap_or_default();
2447                if let Some(functions) = tool.get("tools").and_then(Value::as_array) {
2448                    for function in functions {
2449                        if let Some(name) = function.get("name").and_then(Value::as_str) {
2450                            names.push(name.to_string());
2451                            names.push(format!("{namespace}.{name}"));
2452                        }
2453                    }
2454                }
2455            }
2456            _ => {}
2457        }
2458    }
2459    names
2460}
2461
2462/// Extract function identities only from validated OpenAI Responses
2463/// `tool_search_output.tools` items. Ordinary function calls do not contribute
2464/// loading state.
2465pub fn validated_openai_loaded_tool_names<'a>(
2466    groups: impl IntoIterator<Item = &'a ProviderTranscriptGroup>,
2467    family: ProviderFamily,
2468) -> Vec<String> {
2469    if !matches!(family, ProviderFamily::OpenAi | ProviderFamily::Copilot) {
2470        return Vec::new();
2471    }
2472    let mut names = groups
2473        .into_iter()
2474        .filter(|group| {
2475            group.family() == family
2476                && group.protocol() == ProviderProtocol::OpenAiResponsesV1
2477                && ProviderTranscriptGroup::validate_items(group.items()).is_ok()
2478        })
2479        .flat_map(ProviderTranscriptGroup::items)
2480        .filter(|item| item.kind() == ProviderTranscriptItemKind::OpenAiToolSearchOutput)
2481        .flat_map(|item| openai_loaded_tool_names(item.payload()))
2482        .collect::<Vec<_>>();
2483    names.sort();
2484    names.dedup();
2485    names
2486}
2487
2488fn stable_item_id(
2489    family: ProviderFamily,
2490    protocol: ProviderProtocol,
2491    origin: ProviderTranscriptOrigin,
2492    author: ProviderTranscriptAuthor,
2493    kind: ProviderTranscriptItemKind,
2494    payload: &Value,
2495) -> Result<String, ProviderTranscriptError> {
2496    if !payload.is_object() {
2497        return Err(ProviderTranscriptError::PayloadNotObject);
2498    }
2499    let identity = serde_json::json!({
2500        "family": family,
2501        "protocol": protocol,
2502        "origin": origin,
2503        "author": author,
2504        "kind": kind,
2505        "payload": payload,
2506    });
2507    let digest = hash_json(ITEM_HASH_DOMAIN, &identity);
2508    // Provider ids can be attacker-controlled and may contain prompt or secret
2509    // bytes. Persist/log only the structural kind plus a one-way payload hash.
2510    Ok(format!("pti_{kind:?}_{digest}"))
2511}
2512
2513fn stable_group_id(
2514    epoch: u64,
2515    family: ProviderFamily,
2516    protocol: ProviderProtocol,
2517    provider_boundary_sha256: &str,
2518    anchor_message_id: &str,
2519    _id_hint: Option<&str>,
2520    items: &[ProviderTranscriptItem],
2521) -> String {
2522    let mut hasher = Sha256::new();
2523    hasher.update(GROUP_HASH_DOMAIN);
2524    hasher.update(epoch.to_be_bytes());
2525    hasher.update([0]);
2526    hasher.update(serde_json::to_vec(&family).unwrap_or_default());
2527    hasher.update([0]);
2528    hasher.update(serde_json::to_vec(&protocol).unwrap_or_default());
2529    hasher.update([0]);
2530    hasher.update(provider_boundary_sha256.as_bytes());
2531    hasher.update([0]);
2532    hasher.update(anchor_message_id.as_bytes());
2533    hasher.update([0]);
2534    for item in items {
2535        hasher.update([0]);
2536        hasher.update(item.id.as_bytes());
2537    }
2538    format!("ptg_{}", hex::encode(hasher.finalize()))
2539}
2540
2541fn hash_json(domain: &[u8], value: &Value) -> String {
2542    hash_bytes(
2543        domain,
2544        &serde_json::to_vec(&canonical_json(value)).unwrap_or_default(),
2545    )
2546}
2547
2548fn canonical_json(value: &Value) -> Value {
2549    match value {
2550        Value::Object(object) => {
2551            let mut entries = object.iter().collect::<Vec<_>>();
2552            entries.sort_by_key(|(key, _)| *key);
2553            Value::Object(
2554                entries
2555                    .into_iter()
2556                    .map(|(key, value)| (key.clone(), canonical_json(value)))
2557                    .collect(),
2558            )
2559        }
2560        Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
2561        _ => value.clone(),
2562    }
2563}
2564
2565fn hash_bytes(domain: &[u8], bytes: &[u8]) -> String {
2566    let mut hasher = Sha256::new();
2567    hasher.update(domain);
2568    hasher.update(bytes);
2569    hex::encode(hasher.finalize())
2570}
2571
2572#[cfg(test)]
2573mod tests {
2574    use serde_json::json;
2575
2576    use super::*;
2577    use crate::Message;
2578
2579    fn test_boundary(provider_name: &str, provider_type: &str) -> String {
2580        provider_transcript_boundary_sha256(Some(provider_name), Some(provider_type)).unwrap()
2581    }
2582
2583    fn openai_boundary() -> String {
2584        test_boundary("openai-test", "openai")
2585    }
2586
2587    fn anthropic_boundary() -> String {
2588        test_boundary("anthropic-test", "anthropic")
2589    }
2590
2591    fn activate_openai(session: &mut Session) {
2592        session
2593            .activate_provider_transcript_route(
2594                ProviderFamily::OpenAi,
2595                ProviderProtocol::OpenAiResponsesV1,
2596                &openai_boundary(),
2597            )
2598            .unwrap();
2599    }
2600
2601    fn activate_anthropic(session: &mut Session) -> bool {
2602        session
2603            .activate_provider_transcript_route(
2604                ProviderFamily::Anthropic,
2605                ProviderProtocol::AnthropicMessages2023_06_01,
2606                &anthropic_boundary(),
2607            )
2608            .unwrap()
2609    }
2610
2611    fn replayable_openai(session: &Session) -> Vec<&ProviderTranscriptGroup> {
2612        let boundary = session
2613            .provider_transcript
2614            .active_provider_boundary_sha256()
2615            .expect("provider transcript test route must be active");
2616        session.provider_transcript.replayable_groups(
2617            ProviderFamily::OpenAi,
2618            ProviderProtocol::OpenAiResponsesV1,
2619            boundary,
2620        )
2621    }
2622
2623    fn test_group(
2624        anchor_message_id: impl Into<String>,
2625        items: Vec<ProviderTranscriptItem>,
2626    ) -> Result<ProviderTranscriptGroup, ProviderTranscriptError> {
2627        ProviderTranscriptGroup::new(
2628            0,
2629            0,
2630            anchor_message_id.into(),
2631            None,
2632            unbound_provider_boundary_sha256(),
2633            items,
2634        )
2635    }
2636
2637    fn openai_output_items() -> Vec<ProviderTranscriptItem> {
2638        [
2639            (
2640                ProviderTranscriptAuthor::Model,
2641                json!({
2642                    "type": "tool_search_call",
2643                    "id": "tsc_1",
2644                    "execution": "server",
2645                    "call_id": "search_1",
2646                    "status": "completed",
2647                    "arguments": {"paths": ["crm"]}
2648                }),
2649            ),
2650            (
2651                ProviderTranscriptAuthor::ToolResult,
2652                json!({
2653                    "type": "tool_search_output",
2654                    "id": "tso_1",
2655                    "execution": "server",
2656                    "call_id": "search_1",
2657                    "status": "completed",
2658                    "tools": [{"type": "function", "name": "list_open_orders"}]
2659                }),
2660            ),
2661            (
2662                ProviderTranscriptAuthor::Model,
2663                json!({
2664                    "type": "function_call",
2665                    "call_id": "call_abc123",
2666                    "name": "list_open_orders",
2667                    "arguments": "{\"customer_id\":\"CUST-12345\"}"
2668                }),
2669            ),
2670        ]
2671        .into_iter()
2672        .map(|(author, payload)| {
2673            ProviderTranscriptItem::try_from_payload(
2674                ProviderFamily::OpenAi,
2675                ProviderProtocol::OpenAiResponsesV1,
2676                ProviderTranscriptOrigin::Provider,
2677                author,
2678                payload,
2679            )
2680            .unwrap()
2681        })
2682        .collect()
2683    }
2684
2685    fn anthropic_items() -> Vec<ProviderTranscriptItem> {
2686        [
2687            (
2688                ProviderTranscriptAuthor::Model,
2689                json!({"type":"text","text":"I will search."}),
2690            ),
2691            (
2692                ProviderTranscriptAuthor::Model,
2693                json!({
2694                    "type":"server_tool_use",
2695                    "id":"srvtoolu_01ABC123",
2696                    "name":"tool_search_tool_regex",
2697                    "input":{"pattern":"weather"}
2698                }),
2699            ),
2700            (
2701                ProviderTranscriptAuthor::ToolResult,
2702                json!({
2703                    "type":"tool_search_tool_result",
2704                    "tool_use_id":"srvtoolu_01ABC123",
2705                    "content":{
2706                        "type":"tool_search_tool_search_result",
2707                        "tool_references":[
2708                            {"type":"tool_reference","tool_name":"get_weather"}
2709                        ]
2710                    }
2711                }),
2712            ),
2713            (
2714                ProviderTranscriptAuthor::Model,
2715                json!({
2716                    "type":"tool_use",
2717                    "id":"toolu_01XYZ789",
2718                    "name":"get_weather",
2719                    "input":{"location":"San Francisco"}
2720                }),
2721            ),
2722        ]
2723        .into_iter()
2724        .map(|(author, payload)| {
2725            ProviderTranscriptItem::try_from_payload(
2726                ProviderFamily::Anthropic,
2727                ProviderProtocol::AnthropicMessages2023_06_01,
2728                ProviderTranscriptOrigin::Provider,
2729                author,
2730                payload,
2731            )
2732            .unwrap()
2733        })
2734        .collect()
2735    }
2736
2737    fn assert_payload_rejected_without_leak(
2738        family: ProviderFamily,
2739        protocol: ProviderProtocol,
2740        origin: ProviderTranscriptOrigin,
2741        author: ProviderTranscriptAuthor,
2742        payload: Value,
2743    ) {
2744        let error =
2745            ProviderTranscriptItem::try_from_payload(family, protocol, origin, author, payload)
2746                .expect_err("payload must fail closed");
2747        let diagnostic = format!("{error:?} {error}");
2748        assert!(!diagnostic.contains("UNKNOWN_FIELD_SENTINEL"));
2749    }
2750
2751    fn assert_unknown_top_level_rejected(item: &ProviderTranscriptItem) {
2752        let mut payload = item.payload().clone();
2753        payload
2754            .as_object_mut()
2755            .unwrap()
2756            .insert("unknown".to_string(), json!("UNKNOWN_FIELD_SENTINEL"));
2757        assert_payload_rejected_without_leak(
2758            item.family(),
2759            item.protocol(),
2760            item.origin(),
2761            item.author(),
2762            payload,
2763        );
2764    }
2765
2766    #[test]
2767    fn openai_search_items_round_trip_in_exact_order() {
2768        let items = openai_output_items();
2769        let encoded = serde_json::to_string(&items).unwrap();
2770        let decoded: Vec<ProviderTranscriptItem> = serde_json::from_str(&encoded).unwrap();
2771        assert_eq!(decoded, items);
2772        assert_eq!(
2773            decoded
2774                .iter()
2775                .map(ProviderTranscriptItem::kind)
2776                .collect::<Vec<_>>(),
2777            vec![
2778                ProviderTranscriptItemKind::OpenAiToolSearchCall,
2779                ProviderTranscriptItemKind::OpenAiToolSearchOutput,
2780                ProviderTranscriptItemKind::OpenAiFunctionCall,
2781            ]
2782        );
2783    }
2784
2785    #[test]
2786    fn openai_provider_output_preserves_official_nullable_fields() {
2787        let payloads = [
2788            (
2789                ProviderTranscriptAuthor::Model,
2790                json!({
2791                    "type":"reasoning","id":"rs_nullable","summary":[],
2792                    "agent":null,"content":null,"encrypted_content":null,"status":null
2793                }),
2794            ),
2795            (
2796                ProviderTranscriptAuthor::Model,
2797                json!({
2798                    "type":"message","id":"msg_nullable","role":"assistant",
2799                    "status":"completed","agent":null,"phase":null,
2800                    "content":[{
2801                        "type":"output_text","text":"Searching",
2802                        "annotations":[],"logprobs":null
2803                    }]
2804                }),
2805            ),
2806            (
2807                ProviderTranscriptAuthor::Model,
2808                json!({
2809                    "type":"tool_search_call","id":"tsc_nullable","execution":"server",
2810                    "call_id":null,"status":"completed","arguments":{"query":"orders"},
2811                    "agent":null,"created_by":null
2812                }),
2813            ),
2814            (
2815                ProviderTranscriptAuthor::ToolResult,
2816                json!({
2817                    "type":"tool_search_output","id":"tso_nullable","execution":"server",
2818                    "call_id":null,"status":"completed","agent":null,"created_by":null,
2819                    "tools":[{"type":"function","name":"get_orders"}]
2820                }),
2821            ),
2822            (
2823                ProviderTranscriptAuthor::Model,
2824                json!({
2825                    "type":"function_call","id":null,"call_id":"call_nullable",
2826                    "name":"get_orders","arguments":"{}","agent":null,"caller":null,
2827                    "namespace":null,"status":null,"created_by":null
2828                }),
2829            ),
2830        ];
2831        let expected = payloads
2832            .iter()
2833            .map(|(_, payload)| payload.clone())
2834            .collect::<Vec<_>>();
2835        let items = payloads
2836            .into_iter()
2837            .map(|(author, payload)| {
2838                ProviderTranscriptItem::try_from_payload(
2839                    ProviderFamily::OpenAi,
2840                    ProviderProtocol::OpenAiResponsesV1,
2841                    ProviderTranscriptOrigin::Provider,
2842                    author,
2843                    payload,
2844                )
2845                .unwrap()
2846            })
2847            .collect::<Vec<_>>();
2848
2849        ProviderTranscriptGroup::validate_items(&items).unwrap();
2850        assert_eq!(
2851            items
2852                .iter()
2853                .map(|item| item.payload().clone())
2854                .collect::<Vec<_>>(),
2855            expected
2856        );
2857    }
2858
2859    #[test]
2860    fn anthropic_reference_chain_round_trips_without_schema_expansion() {
2861        let items = anthropic_items();
2862        let encoded = serde_json::to_value(&items).unwrap();
2863        let decoded: Vec<ProviderTranscriptItem> = serde_json::from_value(encoded).unwrap();
2864        assert_eq!(decoded, items);
2865        assert_eq!(
2866            decoded[2].payload()["content"]["tool_references"][0],
2867            json!({"type":"tool_reference","tool_name":"get_weather"})
2868        );
2869    }
2870
2871    #[test]
2872    fn client_output_and_additional_tools_are_position_safe_variants() {
2873        let client = ProviderTranscriptItem::try_from_payload(
2874            ProviderFamily::OpenAi,
2875            ProviderProtocol::OpenAiResponsesV1,
2876            ProviderTranscriptOrigin::HostToolSearch,
2877            ProviderTranscriptAuthor::ToolResult,
2878            json!({
2879                "type":"tool_search_output",
2880                "execution":"client",
2881                "call_id":"call_abc123",
2882                "status":"completed",
2883                "tools":[]
2884            }),
2885        )
2886        .unwrap();
2887        let additional = ProviderTranscriptItem::try_from_payload(
2888            ProviderFamily::OpenAi,
2889            ProviderProtocol::OpenAiResponsesV1,
2890            ProviderTranscriptOrigin::DeveloperContext,
2891            ProviderTranscriptAuthor::Host,
2892            json!({"type":"additional_tools","role":"developer","tools":[]}),
2893        )
2894        .unwrap();
2895        assert_eq!(
2896            client.kind(),
2897            ProviderTranscriptItemKind::OpenAiToolSearchOutput
2898        );
2899        assert_eq!(
2900            additional.kind(),
2901            ProviderTranscriptItemKind::OpenAiAdditionalTools
2902        );
2903        ProviderTranscriptGroup::validate_items(std::slice::from_ref(&client))
2904            .expect("a client output is a standalone host-owned continuation group");
2905    }
2906
2907    #[test]
2908    fn openai_loaded_names_survive_resume_and_come_only_from_search_output() {
2909        let mut session = Session::new("client-loaded-resume", "gpt-5.6");
2910        activate_openai(&mut session);
2911        let assistant = Message::assistant("", None);
2912        let anchor = assistant.id.clone();
2913        session.add_message(assistant);
2914        let call = ProviderTranscriptItem::try_from_payload(
2915            ProviderFamily::OpenAi,
2916            ProviderProtocol::OpenAiResponsesV1,
2917            ProviderTranscriptOrigin::Provider,
2918            ProviderTranscriptAuthor::Model,
2919            json!({
2920                "type":"tool_search_call","id":"tsc_resume","execution":"client",
2921                "call_id":"search_resume","status":"completed",
2922                "arguments":{"query":"files"}
2923            }),
2924        )
2925        .unwrap();
2926        session
2927            .append_provider_transcript_group(&anchor, None, vec![call])
2928            .unwrap();
2929        let output = ProviderTranscriptItem::try_from_payload(
2930            ProviderFamily::OpenAi,
2931            ProviderProtocol::OpenAiResponsesV1,
2932            ProviderTranscriptOrigin::HostToolSearch,
2933            ProviderTranscriptAuthor::ToolResult,
2934            json!({
2935                "type":"tool_search_output","execution":"client",
2936                "call_id":"search_resume","status":"completed","tools":[{
2937                    "type":"function","name":"Glob","description":"Find files",
2938                    "parameters":{"type":"object"},"strict":false,"defer_loading":true
2939                }]
2940            }),
2941        )
2942        .unwrap();
2943        session
2944            .append_provider_transcript_group(&anchor, None, vec![output])
2945            .unwrap();
2946
2947        let names =
2948            validated_openai_loaded_tool_names(replayable_openai(&session), ProviderFamily::OpenAi);
2949        assert_eq!(names, vec!["Glob"]);
2950
2951        let resumed: Session =
2952            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
2953        let resumed_names =
2954            validated_openai_loaded_tool_names(replayable_openai(&resumed), ProviderFamily::OpenAi);
2955        assert_eq!(resumed_names, vec!["Glob"]);
2956
2957        let mut hosted = Session::new("hosted-loaded", "gpt-5.6");
2958        activate_openai(&mut hosted);
2959        let assistant = Message::assistant("normalized", None);
2960        let anchor = assistant.id.clone();
2961        hosted.add_message(assistant);
2962        hosted
2963            .append_provider_transcript_group(&anchor, None, openai_output_items())
2964            .unwrap();
2965        assert_eq!(
2966            validated_openai_loaded_tool_names(replayable_openai(&hosted), ProviderFamily::OpenAi,),
2967            vec!["list_open_orders"],
2968            "the following ordinary function_call must not add loading state"
2969        );
2970    }
2971
2972    #[test]
2973    fn openai_loaded_definitions_distinguish_provider_output_from_host_input() {
2974        for definition in [
2975            json!({"type":"function","name":"get_weather"}),
2976            json!({
2977                "type":"function","name":"get_weather",
2978                "parameters":null,"strict":null,"allowed_callers":null,
2979                "defer_loading":null,"description":null,"output_schema":null
2980            }),
2981        ] {
2982            ProviderTranscriptItem::try_from_payload(
2983                ProviderFamily::OpenAi,
2984                ProviderProtocol::OpenAiResponsesV1,
2985                ProviderTranscriptOrigin::Provider,
2986                ProviderTranscriptAuthor::ToolResult,
2987                json!({
2988                    "type":"tool_search_output","id":"tso_provider","execution":"server",
2989                    "call_id":"search_provider","status":"completed","tools":[definition]
2990                }),
2991            )
2992            .expect("provider output permits omitted or nullable optional definition fields");
2993        }
2994
2995        let host_positions = |definition: Value| {
2996            [
2997                (
2998                    ProviderTranscriptOrigin::HostToolSearch,
2999                    ProviderTranscriptAuthor::ToolResult,
3000                    json!({
3001                        "type":"tool_search_output","execution":"client",
3002                        "call_id":"search_host","status":"completed",
3003                        "tools":[definition.clone()]
3004                    }),
3005                ),
3006                (
3007                    ProviderTranscriptOrigin::DeveloperContext,
3008                    ProviderTranscriptAuthor::Host,
3009                    json!({
3010                        "type":"additional_tools","role":"developer","tools":[definition]
3011                    }),
3012                ),
3013            ]
3014        };
3015
3016        for definition in [
3017            json!({
3018                "type":"function","name":"workflow_run",
3019                "parameters":null,"strict":null
3020            }),
3021            json!({
3022                "type":"function","name":"workflow_run",
3023                "parameters":{"type":"object"},"strict":false
3024            }),
3025        ] {
3026            for (origin, author, payload) in host_positions(definition) {
3027                ProviderTranscriptItem::try_from_payload(
3028                    ProviderFamily::OpenAi,
3029                    ProviderProtocol::OpenAiResponsesV1,
3030                    origin,
3031                    author,
3032                    payload,
3033                )
3034                .expect("host input requires both keys and permits nullable values");
3035            }
3036        }
3037
3038        for definition in [
3039            json!({"type":"function","name":"workflow_run","strict":null}),
3040            json!({
3041                "type":"function","name":"workflow_run",
3042                "parameters":{"type":"object"}
3043            }),
3044            json!({
3045                "type":"function","name":"workflow_run",
3046                "parameters":[],"strict":null
3047            }),
3048            json!({
3049                "type":"function","name":"workflow_run",
3050                "parameters":null,"strict":"false"
3051            }),
3052        ] {
3053            for (origin, author, payload) in host_positions(definition) {
3054                assert!(ProviderTranscriptItem::try_from_payload(
3055                    ProviderFamily::OpenAi,
3056                    ProviderProtocol::OpenAiResponsesV1,
3057                    origin,
3058                    author,
3059                    payload,
3060                )
3061                .is_err());
3062            }
3063        }
3064
3065        let scoped = ProviderTranscriptItem::try_from_payload(
3066            ProviderFamily::OpenAi,
3067            ProviderProtocol::OpenAiResponsesV1,
3068            ProviderTranscriptOrigin::HostToolSearch,
3069            ProviderTranscriptAuthor::ToolResult,
3070            json!({
3071                "type":"tool_search_output","execution":"client","call_id":"search_scoped",
3072                "status":"completed","tools":[{
3073                    "type":"function","name":"load_skill","defer_loading":true,
3074                    "strict":false,
3075                    "parameters":{
3076                        "type":"object",
3077                        "properties":{"skill_id":{"type":"string","const":"skill:pinned@rev-7"}},
3078                        "required":["skill_id"],"additionalProperties":false
3079                    }
3080                }]
3081            }),
3082        )
3083        .expect("host input with a complete pinned parameter schema is valid");
3084        let encoded = serde_json::to_value(&scoped).unwrap();
3085        assert_eq!(
3086            serde_json::from_value::<ProviderTranscriptItem>(encoded).unwrap(),
3087            scoped
3088        );
3089
3090        for nested in [
3091            json!({"type":"function","name":"lookup"}),
3092            json!({"type":"function","name":"lookup","parameters":null,"strict":null}),
3093        ] {
3094            ProviderTranscriptItem::try_from_payload(
3095                ProviderFamily::OpenAi,
3096                ProviderProtocol::OpenAiResponsesV1,
3097                ProviderTranscriptOrigin::DeveloperContext,
3098                ProviderTranscriptAuthor::Host,
3099                json!({
3100                    "type":"additional_tools","role":"developer","tools":[{
3101                        "type":"namespace","name":"crm","description":"CRM tools","tools":[nested]
3102                    }]
3103                }),
3104            )
3105            .expect("namespace member parameters remain optional and nullable");
3106        }
3107    }
3108
3109    #[test]
3110    fn anthropic_builtin_search_inputs_are_name_typed_and_bounded() {
3111        let item = |name: &str, input: Value| {
3112            ProviderTranscriptItem::try_from_payload(
3113                ProviderFamily::Anthropic,
3114                ProviderProtocol::AnthropicMessages2023_06_01,
3115                ProviderTranscriptOrigin::Provider,
3116                ProviderTranscriptAuthor::Model,
3117                json!({
3118                    "type":"server_tool_use","id":"srvtoolu_boundary","name":name,"input":input
3119                }),
3120            )
3121        };
3122
3123        for input in [
3124            json!({"pattern":"x".repeat(200),"limit":1}),
3125            json!({"pattern":"weather","limit":10_000}),
3126        ] {
3127            item("tool_search_tool_regex", input).expect("regex boundary should be valid");
3128        }
3129        for input in [
3130            json!({"query":"x".repeat(500),"limit":1}),
3131            json!({"query":"weather tools","limit":10_000}),
3132        ] {
3133            item("tool_search_tool_bm25", input).expect("BM25 boundary should be valid");
3134        }
3135
3136        let invalid = [
3137            ("tool_search_tool_regex", json!({})),
3138            ("tool_search_tool_regex", json!({"pattern":17})),
3139            ("tool_search_tool_regex", json!({"pattern":"x".repeat(201)})),
3140            ("tool_search_tool_regex", json!({"query":"wrong key"})),
3141            (
3142                "tool_search_tool_regex",
3143                json!({"pattern":"ok","credential":"SEARCH_INPUT_SENTINEL"}),
3144            ),
3145            ("tool_search_tool_bm25", json!({})),
3146            ("tool_search_tool_bm25", json!({"query":17})),
3147            ("tool_search_tool_bm25", json!({"query":"x".repeat(501)})),
3148            ("tool_search_tool_bm25", json!({"pattern":"wrong key"})),
3149            ("tool_search_tool_bm25", json!({"query":"ok","limit":0})),
3150            (
3151                "tool_search_tool_bm25",
3152                json!({"query":"ok","limit":10_001}),
3153            ),
3154            ("tool_search_tool_bm25", json!({"query":"ok","limit":1.5})),
3155            ("tool_search_tool_bm25", json!({"query":"ok","limit":"1"})),
3156        ];
3157        for (name, input) in invalid {
3158            let error = item(name, input).expect_err("invalid search input must fail closed");
3159            let diagnostic = format!("{error:?} {error}");
3160            assert!(!diagnostic.contains("SEARCH_INPUT_SENTINEL"));
3161        }
3162    }
3163
3164    #[test]
3165    fn unsupported_or_malformed_items_fail_closed() {
3166        let unsupported = ProviderTranscriptItem::try_from_payload(
3167            ProviderFamily::OpenAi,
3168            ProviderProtocol::OpenAiResponsesV1,
3169            ProviderTranscriptOrigin::Provider,
3170            ProviderTranscriptAuthor::Model,
3171            json!({"type":"arbitrary_json","secret":"do-not-replay"}),
3172        );
3173        assert!(matches!(
3174            unsupported,
3175            Err(ProviderTranscriptError::UnsupportedItemType)
3176        ));
3177
3178        let malformed = ProviderTranscriptItem::try_from_payload(
3179            ProviderFamily::Anthropic,
3180            ProviderProtocol::AnthropicMessages2023_06_01,
3181            ProviderTranscriptOrigin::Provider,
3182            ProviderTranscriptAuthor::ToolResult,
3183            json!({
3184                "type":"tool_search_tool_result",
3185                "tool_use_id":"srvtoolu_1",
3186                "content":{"type":"tool_search_tool_search_result","tool_references":[
3187                    {"type":"not_a_reference","tool_name":"danger"}
3188                ]}
3189            }),
3190        );
3191        assert!(malformed.is_err());
3192
3193        let mut reversed = openai_output_items();
3194        reversed.swap(0, 1);
3195        let group = test_group("anchor", reversed);
3196        assert_eq!(
3197            group.unwrap_err(),
3198            ProviderTranscriptError::InvalidGroupOrder
3199        );
3200    }
3201
3202    #[test]
3203    fn nested_capability_and_content_shapes_fail_closed_without_payload_leaks() {
3204        let cases = [
3205            ProviderTranscriptItem::try_from_payload(
3206                ProviderFamily::OpenAi,
3207                ProviderProtocol::OpenAiResponsesV1,
3208                ProviderTranscriptOrigin::Provider,
3209                ProviderTranscriptAuthor::Model,
3210                json!({"type":"reasoning","secret":"REASONING_SENTINEL"}),
3211            ),
3212            ProviderTranscriptItem::try_from_payload(
3213                ProviderFamily::OpenAi,
3214                ProviderProtocol::OpenAiResponsesV1,
3215                ProviderTranscriptOrigin::Provider,
3216                ProviderTranscriptAuthor::ToolResult,
3217                json!({
3218                    "type":"tool_search_output","id":"tso_invalid","execution":"server",
3219                    "call_id":"search_invalid",
3220                    "status":"completed","tools":[42],"secret":"TOOLS_SENTINEL"
3221                }),
3222            ),
3223            ProviderTranscriptItem::try_from_payload(
3224                ProviderFamily::OpenAi,
3225                ProviderProtocol::OpenAiResponsesV1,
3226                ProviderTranscriptOrigin::DeveloperContext,
3227                ProviderTranscriptAuthor::Host,
3228                json!({
3229                    "type":"additional_tools","role":"developer",
3230                    "tools":[{"type":"future_capability","secret":"CAP_SENTINEL"}]
3231                }),
3232            ),
3233            ProviderTranscriptItem::try_from_payload(
3234                ProviderFamily::OpenAi,
3235                ProviderProtocol::OpenAiResponsesV1,
3236                ProviderTranscriptOrigin::Provider,
3237                ProviderTranscriptAuthor::Model,
3238                json!({
3239                    "type":"message","id":"msg_invalid","role":"assistant","status":"completed",
3240                    "content":[{"type":"future_content","secret":"CONTENT_SENTINEL"}]
3241                }),
3242            ),
3243            ProviderTranscriptItem::try_from_payload(
3244                ProviderFamily::Anthropic,
3245                ProviderProtocol::AnthropicMessages2023_06_01,
3246                ProviderTranscriptOrigin::Provider,
3247                ProviderTranscriptAuthor::Model,
3248                json!({"type":"thinking","thinking":"private","secret":"THINK_SENTINEL"}),
3249            ),
3250        ];
3251        for result in cases {
3252            let error = result.expect_err("unsupported nested shapes must fail closed");
3253            let diagnostic = format!("{error:?} {error}");
3254            for sentinel in [
3255                "REASONING_SENTINEL",
3256                "TOOLS_SENTINEL",
3257                "CAP_SENTINEL",
3258                "CONTENT_SENTINEL",
3259                "THINK_SENTINEL",
3260            ] {
3261                assert!(!diagnostic.contains(sentinel));
3262            }
3263        }
3264
3265        let reasoning = ProviderTranscriptItem::try_from_payload(
3266            ProviderFamily::OpenAi,
3267            ProviderProtocol::OpenAiResponsesV1,
3268            ProviderTranscriptOrigin::Provider,
3269            ProviderTranscriptAuthor::Model,
3270            json!({
3271                "id":"rs_1","type":"reasoning","status":"completed",
3272                "summary":[{"type":"summary_text","text":"bounded summary"}],
3273                "encrypted_content":"opaque"
3274            }),
3275        )
3276        .unwrap();
3277        assert_eq!(
3278            reasoning.kind(),
3279            ProviderTranscriptItemKind::OpenAiReasoning
3280        );
3281
3282        let thinking = ProviderTranscriptItem::try_from_payload(
3283            ProviderFamily::Anthropic,
3284            ProviderProtocol::AnthropicMessages2023_06_01,
3285            ProviderTranscriptOrigin::Provider,
3286            ProviderTranscriptAuthor::Model,
3287            json!({"type":"thinking","thinking":"private","signature":"signed"}),
3288        )
3289        .unwrap();
3290        assert_eq!(
3291            thinking.kind(),
3292            ProviderTranscriptItemKind::AnthropicThinking
3293        );
3294    }
3295
3296    #[test]
3297    fn every_supported_payload_layer_rejects_unknown_fields() {
3298        let mut baselines = openai_output_items();
3299        baselines.extend(anthropic_items());
3300        baselines.extend([
3301            ProviderTranscriptItem::try_from_payload(
3302                ProviderFamily::OpenAi,
3303                ProviderProtocol::OpenAiResponsesV1,
3304                ProviderTranscriptOrigin::Provider,
3305                ProviderTranscriptAuthor::Model,
3306                json!({
3307                    "type":"message","id":"msg_1","role":"assistant","status":"completed",
3308                    "content":[{"type":"output_text","text":"done","annotations":[]}]
3309                }),
3310            )
3311            .unwrap(),
3312            ProviderTranscriptItem::try_from_payload(
3313                ProviderFamily::OpenAi,
3314                ProviderProtocol::OpenAiResponsesV1,
3315                ProviderTranscriptOrigin::Provider,
3316                ProviderTranscriptAuthor::Model,
3317                json!({
3318                    "type":"reasoning","id":"rs_1","status":"completed",
3319                    "summary":[{"type":"summary_text","text":"safe"}]
3320                }),
3321            )
3322            .unwrap(),
3323            ProviderTranscriptItem::try_from_payload(
3324                ProviderFamily::OpenAi,
3325                ProviderProtocol::OpenAiResponsesV1,
3326                ProviderTranscriptOrigin::HostToolSearch,
3327                ProviderTranscriptAuthor::ToolResult,
3328                json!({
3329                    "type":"tool_search_output","execution":"client","call_id":"search_client",
3330                    "status":"completed","tools":[]
3331                }),
3332            )
3333            .unwrap(),
3334            ProviderTranscriptItem::try_from_payload(
3335                ProviderFamily::OpenAi,
3336                ProviderProtocol::OpenAiResponsesV1,
3337                ProviderTranscriptOrigin::DeveloperContext,
3338                ProviderTranscriptAuthor::Host,
3339                json!({"type":"additional_tools","role":"developer","tools":[]}),
3340            )
3341            .unwrap(),
3342            ProviderTranscriptItem::try_from_payload(
3343                ProviderFamily::Anthropic,
3344                ProviderProtocol::AnthropicMessages2023_06_01,
3345                ProviderTranscriptOrigin::Provider,
3346                ProviderTranscriptAuthor::Model,
3347                json!({"type":"thinking","thinking":"private","signature":"opaque"}),
3348            )
3349            .unwrap(),
3350            ProviderTranscriptItem::try_from_payload(
3351                ProviderFamily::Anthropic,
3352                ProviderProtocol::AnthropicMessages2023_06_01,
3353                ProviderTranscriptOrigin::Provider,
3354                ProviderTranscriptAuthor::Model,
3355                json!({"type":"redacted_thinking","data":"opaque"}),
3356            )
3357            .unwrap(),
3358            ProviderTranscriptItem::try_from_payload(
3359                ProviderFamily::Anthropic,
3360                ProviderProtocol::AnthropicMessages2023_06_01,
3361                ProviderTranscriptOrigin::HostToolSearch,
3362                ProviderTranscriptAuthor::ToolResult,
3363                json!({
3364                    "type":"tool_result","tool_use_id":"toolu_search","is_error":false,
3365                    "content":[{"type":"tool_reference","tool_name":"get_weather"}]
3366                }),
3367            )
3368            .unwrap(),
3369        ]);
3370        for baseline in &baselines {
3371            assert_unknown_top_level_rejected(baseline);
3372        }
3373
3374        assert_payload_rejected_without_leak(
3375            ProviderFamily::OpenAi,
3376            ProviderProtocol::OpenAiResponsesV1,
3377            ProviderTranscriptOrigin::Provider,
3378            ProviderTranscriptAuthor::Model,
3379            json!({
3380                "type":"message","id":"msg_nested","role":"assistant","status":"completed",
3381                "content":[{
3382                    "type":"output_text","text":"safe","annotations":[],
3383                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3384                }]
3385            }),
3386        );
3387        assert_payload_rejected_without_leak(
3388            ProviderFamily::OpenAi,
3389            ProviderProtocol::OpenAiResponsesV1,
3390            ProviderTranscriptOrigin::Provider,
3391            ProviderTranscriptAuthor::Model,
3392            json!({
3393                "type":"message","id":"msg_annotation","role":"assistant","status":"completed",
3394                "content":[{
3395                    "type":"output_text","text":"safe","annotations":[{
3396                        "type":"file_citation","file_id":"file_1","filename":"safe.txt","index":0,
3397                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3398                    }]
3399                }]
3400            }),
3401        );
3402        assert_payload_rejected_without_leak(
3403            ProviderFamily::OpenAi,
3404            ProviderProtocol::OpenAiResponsesV1,
3405            ProviderTranscriptOrigin::Provider,
3406            ProviderTranscriptAuthor::Model,
3407            json!({
3408                "type":"message","id":"msg_logprob","role":"assistant","status":"completed",
3409                "content":[{
3410                    "type":"output_text","text":"safe","annotations":[],"logprobs":[{
3411                        "token":"safe","bytes":[115],"logprob":-0.1,"top_logprobs":[],
3412                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3413                    }]
3414                }]
3415            }),
3416        );
3417        assert_payload_rejected_without_leak(
3418            ProviderFamily::OpenAi,
3419            ProviderProtocol::OpenAiResponsesV1,
3420            ProviderTranscriptOrigin::Provider,
3421            ProviderTranscriptAuthor::Model,
3422            json!({
3423                "type":"reasoning","id":"rs_nested","summary":[{
3424                    "type":"summary_text","text":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"
3425                }]
3426            }),
3427        );
3428        assert_payload_rejected_without_leak(
3429            ProviderFamily::OpenAi,
3430            ProviderProtocol::OpenAiResponsesV1,
3431            ProviderTranscriptOrigin::Provider,
3432            ProviderTranscriptAuthor::ToolResult,
3433            json!({
3434                "type":"tool_search_output","id":"tso_nested","execution":"server",
3435                "call_id":"search_nested","status":"completed","tools":[{
3436                    "type":"function","name":"safe_tool","unknown":"UNKNOWN_FIELD_SENTINEL"
3437                }]
3438            }),
3439        );
3440        assert_payload_rejected_without_leak(
3441            ProviderFamily::OpenAi,
3442            ProviderProtocol::OpenAiResponsesV1,
3443            ProviderTranscriptOrigin::Provider,
3444            ProviderTranscriptAuthor::Model,
3445            json!({
3446                "type":"tool_search_call","id":"tsc_agent","execution":"client",
3447                "call_id":"search_agent","status":"completed","arguments":{},
3448                "agent":{"agent_name":"safe","unknown":"UNKNOWN_FIELD_SENTINEL"}
3449            }),
3450        );
3451        assert_payload_rejected_without_leak(
3452            ProviderFamily::Anthropic,
3453            ProviderProtocol::AnthropicMessages2023_06_01,
3454            ProviderTranscriptOrigin::Provider,
3455            ProviderTranscriptAuthor::Model,
3456            json!({
3457                "type":"text","text":"safe","citations":[{
3458                    "type":"web_search_result_location","cited_text":"safe",
3459                    "encrypted_index":"opaque","title":"safe","url":"https://example.invalid",
3460                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3461                }]
3462            }),
3463        );
3464        assert_payload_rejected_without_leak(
3465            ProviderFamily::Anthropic,
3466            ProviderProtocol::AnthropicMessages2023_06_01,
3467            ProviderTranscriptOrigin::Provider,
3468            ProviderTranscriptAuthor::Model,
3469            json!({
3470                "type":"tool_use","id":"toolu_caller","name":"safe_tool","input":{},
3471                "caller":{"type":"direct","unknown":"UNKNOWN_FIELD_SENTINEL"}
3472            }),
3473        );
3474        assert_payload_rejected_without_leak(
3475            ProviderFamily::Anthropic,
3476            ProviderProtocol::AnthropicMessages2023_06_01,
3477            ProviderTranscriptOrigin::Provider,
3478            ProviderTranscriptAuthor::ToolResult,
3479            json!({
3480                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3481                "content":{
3482                    "type":"tool_search_tool_search_result","tool_references":[
3483                        {"type":"tool_reference","tool_name":"safe_tool"}
3484                    ],
3485                    "unknown":"UNKNOWN_FIELD_SENTINEL"
3486                }
3487            }),
3488        );
3489        assert_payload_rejected_without_leak(
3490            ProviderFamily::Anthropic,
3491            ProviderProtocol::AnthropicMessages2023_06_01,
3492            ProviderTranscriptOrigin::Provider,
3493            ProviderTranscriptAuthor::ToolResult,
3494            json!({
3495                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3496                "content":{
3497                    "type":"tool_search_tool_search_result","tool_references":[{
3498                        "type":"tool_reference","tool_name":"safe_tool",
3499                        "unknown":"UNKNOWN_FIELD_SENTINEL"
3500                    }]
3501                }
3502            }),
3503        );
3504        assert_payload_rejected_without_leak(
3505            ProviderFamily::Anthropic,
3506            ProviderProtocol::AnthropicMessages2023_06_01,
3507            ProviderTranscriptOrigin::Provider,
3508            ProviderTranscriptAuthor::ToolResult,
3509            json!({
3510                "type":"tool_search_tool_result","tool_use_id":"srvtoolu_1",
3511                "content":{
3512                    "type":"tool_search_tool_result_error","error_code":"unavailable",
3513                    "error_message":null,"unknown":"UNKNOWN_FIELD_SENTINEL"
3514                }
3515            }),
3516        );
3517    }
3518
3519    #[test]
3520    fn hosted_discovery_chains_reject_dangling_reordered_and_mismatched_uses() {
3521        let openai = openai_output_items();
3522        let mut reordered = openai.clone();
3523        reordered.swap(1, 2);
3524        assert_eq!(
3525            test_group("openai", reordered).unwrap_err(),
3526            ProviderTranscriptError::InvalidGroupOrder
3527        );
3528        assert_eq!(
3529            test_group("openai", vec![openai[0].clone()]).unwrap_err(),
3530            ProviderTranscriptError::InvalidGroupOrder
3531        );
3532        let mismatched_call = ProviderTranscriptItem::try_from_payload(
3533            ProviderFamily::OpenAi,
3534            ProviderProtocol::OpenAiResponsesV1,
3535            ProviderTranscriptOrigin::Provider,
3536            ProviderTranscriptAuthor::Model,
3537            json!({
3538                "type":"function_call","call_id":"call_other",
3539                "name":"not_loaded","arguments":"{}"
3540            }),
3541        )
3542        .unwrap();
3543        assert_eq!(
3544            test_group(
3545                "openai",
3546                vec![openai[0].clone(), openai[1].clone(), mismatched_call],
3547            )
3548            .unwrap_err(),
3549            ProviderTranscriptError::InvalidGroupOrder
3550        );
3551
3552        let hosted_item = |author, payload| {
3553            ProviderTranscriptItem::try_from_payload(
3554                ProviderFamily::OpenAi,
3555                ProviderProtocol::OpenAiResponsesV1,
3556                ProviderTranscriptOrigin::Provider,
3557                author,
3558                payload,
3559            )
3560            .unwrap()
3561        };
3562        let call_two = hosted_item(
3563            ProviderTranscriptAuthor::Model,
3564            json!({
3565                "type":"tool_search_call","id":"tsc_2","execution":"server",
3566                "call_id":"search_2","status":"completed","arguments":{"query":"support"}
3567            }),
3568        );
3569        let output_two = hosted_item(
3570            ProviderTranscriptAuthor::ToolResult,
3571            json!({
3572                "type":"tool_search_output","id":"tso_2","execution":"server",
3573                "call_id":"search_2","status":"completed",
3574                "tools":[{"type":"function","name":"list_support_cases"}]
3575            }),
3576        );
3577        let function_two = hosted_item(
3578            ProviderTranscriptAuthor::Model,
3579            json!({
3580                "type":"function_call","id":"fc_2","call_id":"function_2",
3581                "name":"list_support_cases","arguments":"{}","status":"completed"
3582            }),
3583        );
3584        let parallel = vec![
3585            openai[0].clone(),
3586            call_two.clone(),
3587            openai[1].clone(),
3588            output_two.clone(),
3589            openai[2].clone(),
3590            function_two,
3591        ];
3592        assert!(test_group("openai-parallel", parallel).is_ok());
3593
3594        let unkeyed_parallel = vec![
3595            hosted_item(
3596                ProviderTranscriptAuthor::Model,
3597                json!({
3598                    "type":"tool_search_call","id":"tsc_unkeyed_1","execution":"server",
3599                    "call_id":null,"status":"completed","arguments":{"query":"first"}
3600                }),
3601            ),
3602            hosted_item(
3603                ProviderTranscriptAuthor::Model,
3604                json!({
3605                    "type":"tool_search_call","id":"tsc_unkeyed_2","execution":"server",
3606                    "status":"completed","arguments":{"query":"second"}
3607                }),
3608            ),
3609            hosted_item(
3610                ProviderTranscriptAuthor::ToolResult,
3611                json!({
3612                    "type":"tool_search_output","id":"tso_unkeyed_1","execution":"server",
3613                    "call_id":null,"status":"completed","tools":[]
3614                }),
3615            ),
3616            hosted_item(
3617                ProviderTranscriptAuthor::ToolResult,
3618                json!({
3619                    "type":"tool_search_output","id":"tso_unkeyed_2","execution":"server",
3620                    "status":"completed","tools":[]
3621                }),
3622            ),
3623        ];
3624        assert!(test_group("openai-unkeyed-parallel", unkeyed_parallel).is_ok());
3625
3626        let mismatched_output = hosted_item(
3627            ProviderTranscriptAuthor::ToolResult,
3628            json!({
3629                "type":"tool_search_output","id":"tso_mismatch","execution":"server",
3630                "call_id":"search_missing","status":"completed","tools":[]
3631            }),
3632        );
3633        assert_eq!(
3634            test_group(
3635                "openai-mismatch",
3636                vec![openai[0].clone(), mismatched_output],
3637            )
3638            .unwrap_err(),
3639            ProviderTranscriptError::InvalidGroupOrder
3640        );
3641
3642        let duplicate_id_call = hosted_item(
3643            ProviderTranscriptAuthor::Model,
3644            json!({
3645                "type":"tool_search_call","id":"tsc_1","execution":"server",
3646                "call_id":"search_duplicate_id","status":"completed","arguments":{}
3647            }),
3648        );
3649        assert_eq!(
3650            test_group(
3651                "openai-duplicate-id",
3652                vec![openai[0].clone(), duplicate_id_call],
3653            )
3654            .unwrap_err(),
3655            ProviderTranscriptError::InvalidGroupOrder
3656        );
3657
3658        let duplicate_call_id = hosted_item(
3659            ProviderTranscriptAuthor::Model,
3660            json!({
3661                "type":"tool_search_call","id":"tsc_duplicate_call","execution":"server",
3662                "call_id":"search_1","status":"completed","arguments":{}
3663            }),
3664        );
3665        assert_eq!(
3666            test_group(
3667                "openai-duplicate-call",
3668                vec![openai[0].clone(), duplicate_call_id],
3669            )
3670            .unwrap_err(),
3671            ProviderTranscriptError::InvalidGroupOrder
3672        );
3673        assert!(ProviderTranscriptItem::try_from_payload(
3674            ProviderFamily::OpenAi,
3675            ProviderProtocol::OpenAiResponsesV1,
3676            ProviderTranscriptOrigin::Provider,
3677            ProviderTranscriptAuthor::Model,
3678            json!({
3679                "type":"tool_search_call","execution":"server","call_id":"search_missing_id",
3680                "status":"completed","arguments":{}
3681            }),
3682        )
3683        .is_err());
3684        for call_id in [json!(""), json!(7)] {
3685            assert!(ProviderTranscriptItem::try_from_payload(
3686                ProviderFamily::OpenAi,
3687                ProviderProtocol::OpenAiResponsesV1,
3688                ProviderTranscriptOrigin::Provider,
3689                ProviderTranscriptAuthor::Model,
3690                json!({
3691                    "type":"tool_search_call","id":"tsc_invalid_call","execution":"server",
3692                    "call_id":call_id,"status":"completed","arguments":{}
3693                }),
3694            )
3695            .is_err());
3696        }
3697        ProviderTranscriptItem::try_from_payload(
3698            ProviderFamily::OpenAi,
3699            ProviderProtocol::OpenAiResponsesV1,
3700            ProviderTranscriptOrigin::Provider,
3701            ProviderTranscriptAuthor::Model,
3702            json!({
3703                "type":"tool_search_call","id":"tsc_missing_call","execution":"server",
3704                "status":"completed","arguments":{}
3705            }),
3706        )
3707        .expect("the official hosted output model permits an omitted call_id");
3708
3709        for malformed_client in [
3710            json!({
3711                "type":"tool_search_call","id":"tsc_client_missing","execution":"client",
3712                "status":"completed","arguments":{}
3713            }),
3714            json!({
3715                "type":"tool_search_call","id":"tsc_client_null","execution":"client",
3716                "call_id":null,"status":"completed","arguments":{}
3717            }),
3718            json!({
3719                "type":"tool_search_call","id":"tsc_client_empty","execution":"client",
3720                "call_id":"","status":"completed","arguments":{}
3721            }),
3722            json!({
3723                "type":"tool_search_call","id":"tsc_client_typed","execution":"client",
3724                "call_id":7,"status":"completed","arguments":{}
3725            }),
3726        ] {
3727            assert!(ProviderTranscriptItem::try_from_payload(
3728                ProviderFamily::OpenAi,
3729                ProviderProtocol::OpenAiResponsesV1,
3730                ProviderTranscriptOrigin::Provider,
3731                ProviderTranscriptAuthor::Model,
3732                malformed_client,
3733            )
3734            .is_err());
3735        }
3736
3737        // Client execution intentionally stops after the call. Its host output
3738        // is committed in a later input group and may therefore stand alone.
3739        let client_call = ProviderTranscriptItem::try_from_payload(
3740            ProviderFamily::OpenAi,
3741            ProviderProtocol::OpenAiResponsesV1,
3742            ProviderTranscriptOrigin::Provider,
3743            ProviderTranscriptAuthor::Model,
3744            json!({
3745                "type":"tool_search_call","id":"tsc_client_1","execution":"client","call_id":"search_1",
3746                "status":"completed","arguments":{"query":"weather"}
3747            }),
3748        )
3749        .unwrap();
3750        assert!(test_group("client-call", vec![client_call.clone()]).is_ok());
3751        let premature_function_call = hosted_item(
3752            ProviderTranscriptAuthor::Model,
3753            json!({
3754                "type":"function_call","id":"fc_client_premature","call_id":"function_1",
3755                "name":"get_weather","arguments":"{}","status":"completed"
3756            }),
3757        );
3758        assert_eq!(
3759            test_group(
3760                "client-call-must-stop",
3761                vec![client_call.clone(), premature_function_call.clone()],
3762            )
3763            .unwrap_err(),
3764            ProviderTranscriptError::InvalidGroupOrder
3765        );
3766        assert_eq!(
3767            test_group(
3768                "function-cannot-precede-client-call",
3769                vec![premature_function_call, client_call.clone()],
3770            )
3771            .unwrap_err(),
3772            ProviderTranscriptError::InvalidGroupOrder
3773        );
3774
3775        let host_client_output = ProviderTranscriptItem::try_from_payload(
3776            ProviderFamily::OpenAi,
3777            ProviderProtocol::OpenAiResponsesV1,
3778            ProviderTranscriptOrigin::HostToolSearch,
3779            ProviderTranscriptAuthor::ToolResult,
3780            json!({
3781                "type":"tool_search_output","execution":"client",
3782                "call_id":"search_1","status":"completed","tools":[]
3783            }),
3784        )
3785        .unwrap();
3786        assert_eq!(
3787            test_group(
3788                "client-call-cannot-contain-host-output",
3789                vec![client_call.clone(), host_client_output.clone()],
3790            )
3791            .unwrap_err(),
3792            ProviderTranscriptError::InvalidGroupOrder
3793        );
3794        assert_eq!(
3795            test_group(
3796                "host-output-cannot-precede-client-call",
3797                vec![host_client_output.clone(), client_call],
3798            )
3799            .unwrap_err(),
3800            ProviderTranscriptError::InvalidGroupOrder
3801        );
3802        assert!(test_group("standalone-host-output", vec![host_client_output]).is_ok());
3803
3804        let anthropic = anthropic_items();
3805        let mut reordered = anthropic.clone();
3806        reordered.swap(2, 3);
3807        assert_eq!(
3808            test_group("anthropic", reordered).unwrap_err(),
3809            ProviderTranscriptError::InvalidGroupOrder
3810        );
3811        assert_eq!(
3812            test_group("anthropic", vec![anthropic[1].clone()]).unwrap_err(),
3813            ProviderTranscriptError::InvalidGroupOrder
3814        );
3815        let mismatched_use = ProviderTranscriptItem::try_from_payload(
3816            ProviderFamily::Anthropic,
3817            ProviderProtocol::AnthropicMessages2023_06_01,
3818            ProviderTranscriptOrigin::Provider,
3819            ProviderTranscriptAuthor::Model,
3820            json!({"type":"tool_use","id":"tool_other","name":"not_loaded","input":{}}),
3821        )
3822        .unwrap();
3823        assert_eq!(
3824            test_group(
3825                "anthropic",
3826                vec![anthropic[1].clone(), anthropic[2].clone(), mismatched_use],
3827            )
3828            .unwrap_err(),
3829            ProviderTranscriptError::InvalidGroupOrder
3830        );
3831    }
3832
3833    #[test]
3834    fn anthropic_groups_require_one_leading_thinking_block_and_unique_tool_use_ids() {
3835        let item = |author, payload| {
3836            ProviderTranscriptItem::try_from_payload(
3837                ProviderFamily::Anthropic,
3838                ProviderProtocol::AnthropicMessages2023_06_01,
3839                ProviderTranscriptOrigin::Provider,
3840                author,
3841                payload,
3842            )
3843            .unwrap()
3844        };
3845        let thinking = item(
3846            ProviderTranscriptAuthor::Model,
3847            json!({"type":"thinking","thinking":"private","signature":"signed"}),
3848        );
3849        let redacted = item(
3850            ProviderTranscriptAuthor::Model,
3851            json!({"type":"redacted_thinking","data":"opaque"}),
3852        );
3853        let base = anthropic_items();
3854
3855        let mut leading = vec![thinking.clone()];
3856        leading.extend(base.clone());
3857        assert!(test_group("anthropic-leading-thinking", leading).is_ok());
3858
3859        let mut interior = base.clone();
3860        interior.insert(1, thinking.clone());
3861        assert_eq!(
3862            test_group("anthropic-interior-thinking", interior).unwrap_err(),
3863            ProviderTranscriptError::InvalidGroupOrder
3864        );
3865
3866        let mut multiple = vec![thinking, redacted];
3867        multiple.extend(base.clone());
3868        assert_eq!(
3869            test_group("anthropic-multiple-thinking", multiple).unwrap_err(),
3870            ProviderTranscriptError::InvalidGroupOrder
3871        );
3872
3873        let duplicate_id = item(
3874            ProviderTranscriptAuthor::Model,
3875            json!({
3876                "type":"tool_use","id":"toolu_01XYZ789",
3877                "name":"get_weather","input":{}
3878            }),
3879        );
3880        let mut duplicate = base.clone();
3881        duplicate.push(duplicate_id);
3882        assert_eq!(
3883            test_group("anthropic-duplicate-tool-id", duplicate).unwrap_err(),
3884            ProviderTranscriptError::InvalidGroupOrder
3885        );
3886
3887        let server_id_collision = item(
3888            ProviderTranscriptAuthor::Model,
3889            json!({
3890                "type":"tool_use","id":"srvtoolu_01ABC123",
3891                "name":"get_weather","input":{}
3892            }),
3893        );
3894        let mut collision = base;
3895        collision.push(server_id_collision);
3896        assert_eq!(
3897            test_group("anthropic-cross-type-tool-id", collision).unwrap_err(),
3898            ProviderTranscriptError::InvalidGroupOrder
3899        );
3900    }
3901
3902    #[test]
3903    fn anthropic_parallel_search_results_pair_by_server_id() {
3904        let item = |author, payload| {
3905            ProviderTranscriptItem::try_from_payload(
3906                ProviderFamily::Anthropic,
3907                ProviderProtocol::AnthropicMessages2023_06_01,
3908                ProviderTranscriptOrigin::Provider,
3909                author,
3910                payload,
3911            )
3912            .unwrap()
3913        };
3914        let parallel = vec![
3915            item(
3916                ProviderTranscriptAuthor::Model,
3917                json!({
3918                    "type":"server_tool_use","id":"srv_1",
3919                    "name":"tool_search_tool_regex","input":{"pattern":"weather"}
3920                }),
3921            ),
3922            item(
3923                ProviderTranscriptAuthor::Model,
3924                json!({
3925                    "type":"server_tool_use","id":"srv_2",
3926                    "name":"tool_search_tool_bm25","input":{"query":"calendar"}
3927                }),
3928            ),
3929            item(
3930                ProviderTranscriptAuthor::ToolResult,
3931                json!({
3932                    "type":"tool_search_tool_result","tool_use_id":"srv_2",
3933                    "content":{"type":"tool_search_tool_search_result","tool_references":[
3934                        {"type":"tool_reference","tool_name":"get_calendar"}
3935                    ]}
3936                }),
3937            ),
3938            item(
3939                ProviderTranscriptAuthor::ToolResult,
3940                json!({
3941                    "type":"tool_search_tool_result","tool_use_id":"srv_1",
3942                    "content":{"type":"tool_search_tool_search_result","tool_references":[
3943                        {"type":"tool_reference","tool_name":"get_weather"}
3944                    ]}
3945                }),
3946            ),
3947            item(
3948                ProviderTranscriptAuthor::Model,
3949                json!({"type":"tool_use","id":"tool_1","name":"get_weather","input":{}}),
3950            ),
3951            item(
3952                ProviderTranscriptAuthor::Model,
3953                json!({"type":"tool_use","id":"tool_2","name":"get_calendar","input":{}}),
3954            ),
3955        ];
3956        assert!(test_group("anthropic-parallel", parallel.clone()).is_ok());
3957
3958        let mut result_before_call = parallel;
3959        result_before_call.swap(1, 2);
3960        assert_eq!(
3961            test_group("anthropic-result-before-call", result_before_call).unwrap_err(),
3962            ProviderTranscriptError::InvalidGroupOrder
3963        );
3964    }
3965
3966    #[test]
3967    fn stable_ids_are_canonical_and_isolated_by_provider_identity() {
3968        let payload_a: Value = serde_json::from_str(
3969            r#"{"type":"tool_search_call","id":"tsc_stable","execution":"client","call_id":"search_1","status":"completed","arguments":{"b":2,"a":1}}"#,
3970        )
3971        .unwrap();
3972        let payload_b: Value = serde_json::from_str(
3973            r#"{"arguments":{"a":1,"b":2},"status":"completed","call_id":"search_1","execution":"client","id":"tsc_stable","type":"tool_search_call"}"#,
3974        )
3975        .unwrap();
3976        let item = |family, payload| {
3977            ProviderTranscriptItem::try_from_payload(
3978                family,
3979                ProviderProtocol::OpenAiResponsesV1,
3980                ProviderTranscriptOrigin::Provider,
3981                ProviderTranscriptAuthor::Model,
3982                payload,
3983            )
3984            .unwrap()
3985        };
3986        let openai_a = item(ProviderFamily::OpenAi, payload_a);
3987        let openai_b = item(ProviderFamily::OpenAi, payload_b);
3988        let copilot = item(ProviderFamily::Copilot, openai_a.payload().clone());
3989        assert_eq!(openai_a.id(), openai_b.id());
3990        assert_ne!(openai_a.id(), copilot.id());
3991
3992        let route_a = test_boundary("openai-route-a", "openai");
3993        let route_b = test_boundary("openai-route-b", "openai");
3994        let openai_group = ProviderTranscriptGroup::new(
3995            0,
3996            0,
3997            "anchor".to_string(),
3998            None,
3999            route_a.clone(),
4000            vec![openai_a.clone()],
4001        )
4002        .unwrap();
4003        let other_route_group =
4004            ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_b, vec![openai_a])
4005                .unwrap();
4006        let copilot_group =
4007            ProviderTranscriptGroup::new(0, 0, "anchor".to_string(), None, route_a, vec![copilot])
4008                .unwrap();
4009        assert_ne!(openai_group.id(), other_route_group.id());
4010        assert_ne!(openai_group.id(), copilot_group.id());
4011
4012        let mut tampered = serde_json::to_value(openai_b).unwrap();
4013        tampered["id"] = json!("pti_tampered");
4014        assert!(serde_json::from_value::<ProviderTranscriptItem>(tampered).is_err());
4015    }
4016
4017    #[test]
4018    fn session_serialization_preserves_groups_and_old_sessions_default_empty() {
4019        let mut session = Session::new("session-native", "gpt-5.6");
4020        let assistant = Message::assistant("", None);
4021        let anchor = assistant.id.clone();
4022        session.add_message(assistant);
4023        activate_openai(&mut session);
4024        session
4025            .append_provider_transcript_group(&anchor, Some("resp_123"), openai_output_items())
4026            .unwrap();
4027
4028        let encoded = serde_json::to_string(&session).unwrap();
4029        let decoded: Session = serde_json::from_str(&encoded).unwrap();
4030        assert_eq!(decoded.provider_transcript, session.provider_transcript);
4031        assert_eq!(replayable_openai(&decoded).len(), 1);
4032        let mut compressed = decoded;
4033        compressed.reset_model_context_epoch(crate::session::ModelContextResetReason::Compression);
4034        assert!(replayable_openai(&compressed).is_empty());
4035        assert_eq!(compressed.provider_transcript.groups().len(), 1);
4036
4037        let mut old = serde_json::to_value(Session::new("old", "model")).unwrap();
4038        old.as_object_mut().unwrap().remove("provider_transcript");
4039        let old: Session = serde_json::from_value(old).unwrap();
4040        assert!(old.provider_transcript.is_empty());
4041    }
4042
4043    #[test]
4044    fn persisted_state_rejects_future_epochs_and_reused_current_sequences() {
4045        let mut session = Session::new("session-native", "gpt-5.6");
4046        let assistant = Message::assistant("", None);
4047        let anchor = assistant.id.clone();
4048        session.add_message(assistant);
4049        session
4050            .append_provider_transcript_group(&anchor, None, openai_output_items())
4051            .unwrap();
4052
4053        let mut future = serde_json::to_value(&session).unwrap();
4054        future["provider_transcript"]["groups"][0]["epoch"] = json!(1);
4055        assert!(serde_json::from_value::<Session>(future).is_err());
4056
4057        let mut reused = serde_json::to_value(&session).unwrap();
4058        reused["provider_transcript"]["next_sequence"] = json!(0);
4059        assert!(serde_json::from_value::<Session>(reused).is_err());
4060
4061        let mut missing_version = serde_json::to_value(&session).unwrap();
4062        missing_version["provider_transcript"]
4063            .as_object_mut()
4064            .unwrap()
4065            .remove("schema_version");
4066        assert!(serde_json::from_value::<Session>(missing_version).is_err());
4067
4068        let mut future_version = serde_json::to_value(&session).unwrap();
4069        future_version["provider_transcript"]["schema_version"] =
4070            json!(PROVIDER_TRANSCRIPT_SCHEMA_VERSION + 1);
4071        assert!(serde_json::from_value::<Session>(future_version).is_err());
4072
4073        let explicitly_empty: ProviderTranscriptState = serde_json::from_value(json!({})).unwrap();
4074        assert!(explicitly_empty.is_empty());
4075    }
4076
4077    #[test]
4078    fn rollback_prunes_the_whole_atomic_group() {
4079        let mut session = Session::new("session-native", "claude");
4080        let assistant = Message::assistant("", None);
4081        let anchor = assistant.id.clone();
4082        session.add_message(assistant);
4083        activate_anthropic(&mut session);
4084        session
4085            .append_provider_transcript_group(&anchor, None, anthropic_items())
4086            .unwrap();
4087        session.messages.clear();
4088
4089        assert_eq!(session.prune_provider_transcript(), 1);
4090        assert!(session.provider_transcript.groups().is_empty());
4091        assert_eq!(
4092            session.provider_transcript.last_reset_reason(),
4093            Some(ProviderTranscriptResetReason::Rollback)
4094        );
4095    }
4096
4097    #[test]
4098    fn explicit_history_rewrite_invalidates_groups_even_when_anchors_survive() {
4099        let mut session = Session::new("session-native", "gpt-5.6");
4100        let assistant = Message::assistant("before edit", None);
4101        let anchor = assistant.id.clone();
4102        session.add_message(assistant);
4103        session
4104            .append_provider_transcript_group(&anchor, None, openai_output_items())
4105            .unwrap();
4106        let previous_epoch = session.provider_transcript.epoch();
4107
4108        session.messages[0].content = "after edit".to_string();
4109        session.reset_model_context_epoch(
4110            crate::session::ModelContextResetReason::ExplicitHistoryRewrite,
4111        );
4112
4113        assert_eq!(session.provider_transcript.groups().len(), 1);
4114        assert_eq!(session.provider_transcript.epoch(), previous_epoch + 1);
4115        assert!(replayable_openai(&session).is_empty());
4116        assert_eq!(
4117            session.provider_transcript.last_reset_reason(),
4118            Some(ProviderTranscriptResetReason::ExplicitHistoryRewrite)
4119        );
4120    }
4121
4122    #[test]
4123    fn durable_and_runner_native_groups_merge_append_safely() {
4124        let mut base = Session::new("session-native", "gpt-5.6");
4125        let first = Message::assistant("first", None);
4126        let first_id = first.id.clone();
4127        let second = Message::assistant("second", None);
4128        let second_id = second.id.clone();
4129        base.add_message(first);
4130        base.add_message(second);
4131        let mut durable = base.clone();
4132        durable
4133            .append_provider_transcript_group(&first_id, None, openai_output_items())
4134            .unwrap();
4135        let mut runner = base;
4136        runner
4137            .append_provider_transcript_group(&second_id, None, openai_output_items())
4138            .unwrap();
4139
4140        crate::session::append_missing_runtime_messages(&mut runner, &durable);
4141        assert_eq!(runner.provider_transcript.groups().len(), 2);
4142        assert_eq!(
4143            replayable_openai(&runner)
4144                .into_iter()
4145                .map(ProviderTranscriptGroup::anchor_message_id)
4146                .collect::<Vec<_>>(),
4147            vec![first_id.as_str(), second_id.as_str()]
4148        );
4149    }
4150
4151    #[test]
4152    fn equal_revision_merge_is_deterministic_for_same_anchor_branches() {
4153        let assistant = Message::assistant("anchor", None);
4154        let anchor = assistant.id.clone();
4155        let mut base = Session::new("session-native", "gpt-5.6");
4156        base.add_message(assistant);
4157
4158        let mut left = base.clone();
4159        left.append_provider_transcript_group(&anchor, None, openai_output_items())
4160            .unwrap();
4161        let mut right_items = openai_output_items();
4162        right_items[0] = ProviderTranscriptItem::try_from_payload(
4163            ProviderFamily::OpenAi,
4164            ProviderProtocol::OpenAiResponsesV1,
4165            ProviderTranscriptOrigin::Provider,
4166            ProviderTranscriptAuthor::Model,
4167            json!({
4168                "type":"tool_search_call","id":"tsc_support","execution":"server","call_id":"search_1",
4169                "status":"completed","arguments":{"paths":["support"]}
4170            }),
4171        )
4172        .unwrap();
4173        let mut right = base;
4174        right
4175            .append_provider_transcript_group(&anchor, None, right_items)
4176            .unwrap();
4177
4178        let left_original = left.provider_transcript.clone();
4179        let right_original = right.provider_transcript.clone();
4180        let ordered = vec![anchor];
4181        left.provider_transcript
4182            .merge_durable_prefix(&right_original, &ordered);
4183        right
4184            .provider_transcript
4185            .merge_durable_prefix(&left_original, &ordered);
4186        assert_eq!(left.provider_transcript, right.provider_transcript);
4187        assert_eq!(replayable_openai(&left).len(), 2);
4188    }
4189
4190    #[test]
4191    fn rejected_append_is_atomic_and_reset_reason_survives_new_epoch_append() {
4192        let mut state = ProviderTranscriptState::default();
4193        state
4194            .activate_route(
4195                ProviderFamily::Anthropic,
4196                ProviderProtocol::AnthropicMessages2023_06_01,
4197                &anthropic_boundary(),
4198            )
4199            .unwrap();
4200        let before = state.clone();
4201        assert_eq!(
4202            state
4203                .append_group("anchor", None, openai_output_items())
4204                .unwrap_err(),
4205            ProviderTranscriptError::InactiveProviderRoute
4206        );
4207        assert_eq!(state, before, "rejected append must not mutate state");
4208
4209        let first = Message::assistant("openai", None);
4210        let first_anchor = first.id.clone();
4211        let second = Message::assistant("anthropic", None);
4212        let second_anchor = second.id.clone();
4213        let mut session = Session::new("switch-audit", "model");
4214        session.add_message(first);
4215        session.add_message(second);
4216        session
4217            .append_provider_transcript_group(&first_anchor, None, openai_output_items())
4218            .unwrap();
4219        activate_anthropic(&mut session);
4220        session
4221            .append_provider_transcript_group(&second_anchor, None, anthropic_items())
4222            .unwrap();
4223        let round_trip: Session =
4224            serde_json::from_str(&serde_json::to_string(&session).unwrap()).unwrap();
4225        assert_eq!(
4226            round_trip.provider_transcript.last_reset_reason(),
4227            Some(ProviderTranscriptResetReason::ProviderSwitch)
4228        );
4229        assert_eq!(
4230            round_trip
4231                .provider_transcript
4232                .replayable_groups(
4233                    ProviderFamily::Anthropic,
4234                    ProviderProtocol::AnthropicMessages2023_06_01,
4235                    &anthropic_boundary(),
4236                )
4237                .len(),
4238            1
4239        );
4240    }
4241
4242    #[test]
4243    fn append_merge_never_overwrites_a_newer_provider_epoch_with_more_old_groups() {
4244        let mut base = Session::new("session-native", "model");
4245        let anchors = (0..3)
4246            .map(|index| {
4247                let assistant = Message::assistant(format!("assistant {index}"), None);
4248                let anchor = assistant.id.clone();
4249                base.add_message(assistant);
4250                anchor
4251            })
4252            .collect::<Vec<_>>();
4253        let mut durable = base.clone();
4254        durable
4255            .append_provider_transcript_group(&anchors[0], None, openai_output_items())
4256            .unwrap();
4257        durable
4258            .append_provider_transcript_group(&anchors[1], None, openai_output_items())
4259            .unwrap();
4260
4261        let mut runner = base;
4262        runner
4263            .append_provider_transcript_group(&anchors[2], None, openai_output_items())
4264            .unwrap();
4265        activate_anthropic(&mut runner);
4266        let switched_epoch = runner.provider_transcript.epoch();
4267
4268        crate::session::append_missing_runtime_messages(&mut runner, &durable);
4269        assert_eq!(runner.provider_transcript.epoch(), switched_epoch);
4270        assert_eq!(
4271            runner.provider_transcript.active_family(),
4272            Some(ProviderFamily::Anthropic)
4273        );
4274        assert_eq!(
4275            runner.provider_transcript.last_reset_reason(),
4276            Some(ProviderTranscriptResetReason::ProviderSwitch)
4277        );
4278        assert!(replayable_openai(&runner).is_empty());
4279    }
4280
4281    #[test]
4282    fn provider_switch_starts_a_new_epoch_and_filters_foreign_json() {
4283        let mut session = Session::new("session-native", "model");
4284        let assistant = Message::assistant("", None);
4285        let anchor = assistant.id.clone();
4286        session.add_message(assistant);
4287        activate_openai(&mut session);
4288        session
4289            .append_provider_transcript_group(&anchor, None, openai_output_items())
4290            .unwrap();
4291        let old_epoch = session.provider_transcript.epoch();
4292
4293        assert!(activate_anthropic(&mut session));
4294        assert_eq!(session.provider_transcript.epoch(), old_epoch + 1);
4295        assert_eq!(
4296            session.provider_transcript.last_reset_reason(),
4297            Some(ProviderTranscriptResetReason::ProviderSwitch)
4298        );
4299        assert!(replayable_openai(&session).is_empty());
4300    }
4301
4302    #[test]
4303    fn same_family_provider_instance_switch_advances_and_persists_route_boundary() {
4304        let route_a_name = "openai-instance-a-sensitive-route";
4305        let route_b_name = "openai-instance-b-sensitive-route";
4306        let route_a = test_boundary(route_a_name, "openai");
4307        let route_b = test_boundary(route_b_name, "openai");
4308        assert_ne!(route_a, route_b);
4309        assert!(!route_a.contains(route_a_name));
4310
4311        let mut session = Session::new("same-family-switch", "model");
4312        let assistant = Message::assistant("native", None);
4313        let anchor = assistant.id.clone();
4314        session.add_message(assistant);
4315        assert!(session
4316            .activate_provider_transcript_route(
4317                ProviderFamily::OpenAi,
4318                ProviderProtocol::OpenAiResponsesV1,
4319                &route_a,
4320            )
4321            .unwrap());
4322        session
4323            .append_provider_transcript_group(&anchor, None, openai_output_items())
4324            .unwrap();
4325        let first_epoch = session.provider_transcript.epoch();
4326        assert_eq!(
4327            session
4328                .provider_transcript
4329                .active_provider_boundary_sha256(),
4330            Some(route_a.as_str())
4331        );
4332        assert!(!session
4333            .activate_provider_transcript_route(
4334                ProviderFamily::OpenAi,
4335                ProviderProtocol::OpenAiResponsesV1,
4336                &route_a,
4337            )
4338            .unwrap());
4339        assert_eq!(session.provider_transcript.epoch(), first_epoch);
4340
4341        let encoded = serde_json::to_string(&session.provider_transcript).unwrap();
4342        assert!(!encoded.contains(route_a_name));
4343        assert!(!encoded.contains(route_b_name));
4344
4345        let mut active_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4346        active_boundary_tamper["active_provider_boundary_sha256"] = json!(route_b);
4347        assert!(
4348            serde_json::from_value::<ProviderTranscriptState>(active_boundary_tamper).is_err(),
4349            "a valid but foreign active boundary must not reclassify current groups"
4350        );
4351
4352        let mut group_boundary_tamper: Value = serde_json::from_str(&encoded).unwrap();
4353        group_boundary_tamper["groups"][0]["provider_boundary_sha256"] = json!(route_b);
4354        assert!(
4355            serde_json::from_value::<ProviderTranscriptState>(group_boundary_tamper).is_err(),
4356            "a valid but foreign group boundary must invalidate its stable id"
4357        );
4358
4359        let mut resumed: ProviderTranscriptState = serde_json::from_str(&encoded).unwrap();
4360        assert_eq!(
4361            resumed.active_provider_boundary_sha256(),
4362            Some(route_a.as_str())
4363        );
4364        assert!(resumed
4365            .activate_route(
4366                ProviderFamily::OpenAi,
4367                ProviderProtocol::OpenAiResponsesV1,
4368                &route_b,
4369            )
4370            .unwrap());
4371        assert_eq!(resumed.epoch(), first_epoch + 1);
4372        assert_eq!(
4373            resumed.last_reset_reason(),
4374            Some(ProviderTranscriptResetReason::ProviderSwitch)
4375        );
4376        assert!(resumed
4377            .replayable_groups(
4378                ProviderFamily::OpenAi,
4379                ProviderProtocol::OpenAiResponsesV1,
4380                &route_a,
4381            )
4382            .is_empty());
4383        assert!(resumed
4384            .replayable_groups(
4385                ProviderFamily::OpenAi,
4386                ProviderProtocol::OpenAiResponsesV1,
4387                &route_b,
4388            )
4389            .is_empty());
4390
4391        let mut invalid = serde_json::to_value(&resumed).unwrap();
4392        invalid["active_provider_boundary_sha256"] = json!("not-a-sha256");
4393        assert!(serde_json::from_value::<ProviderTranscriptState>(invalid).is_err());
4394    }
4395
4396    #[test]
4397    fn stale_same_family_route_cannot_resurrect_groups_during_merge() {
4398        let route_a = test_boundary("openai-instance-a", "openai");
4399        let route_b = test_boundary("openai-instance-b", "openai");
4400        let mut base = Session::new("same-family-merge", "model");
4401        let first = Message::assistant("first", None);
4402        let first_anchor = first.id.clone();
4403        let second = Message::assistant("second", None);
4404        let second_anchor = second.id.clone();
4405        base.add_message(first);
4406        base.add_message(second);
4407        base.activate_provider_transcript_route(
4408            ProviderFamily::OpenAi,
4409            ProviderProtocol::OpenAiResponsesV1,
4410            &route_a,
4411        )
4412        .unwrap();
4413        base.append_provider_transcript_group(&first_anchor, None, openai_output_items())
4414            .unwrap();
4415
4416        let mut durable = base.clone();
4417        durable
4418            .activate_provider_transcript_route(
4419                ProviderFamily::OpenAi,
4420                ProviderProtocol::OpenAiResponsesV1,
4421                &route_b,
4422            )
4423            .unwrap();
4424        let switched_epoch = durable.provider_transcript.epoch();
4425
4426        let mut stale_runner = base;
4427        stale_runner
4428            .append_provider_transcript_group(&second_anchor, None, openai_output_items())
4429            .unwrap();
4430        stale_runner.merge_provider_transcript_from_durable(&durable);
4431        assert_eq!(stale_runner.provider_transcript.epoch(), switched_epoch);
4432        assert_eq!(
4433            stale_runner
4434                .provider_transcript
4435                .active_provider_boundary_sha256(),
4436            Some(route_b.as_str())
4437        );
4438        assert!(stale_runner
4439            .provider_transcript
4440            .replayable_groups(
4441                ProviderFamily::OpenAi,
4442                ProviderProtocol::OpenAiResponsesV1,
4443                &route_b,
4444            )
4445            .is_empty());
4446        assert_eq!(
4447            stale_runner.provider_transcript.last_reset_reason(),
4448            Some(ProviderTranscriptResetReason::ProviderSwitch)
4449        );
4450    }
4451
4452    #[test]
4453    fn debug_and_diagnostics_never_emit_raw_payload() {
4454        let secret = "credential-do-not-log";
4455        let item = ProviderTranscriptItem::try_from_payload(
4456            ProviderFamily::OpenAi,
4457            ProviderProtocol::OpenAiResponsesV1,
4458            ProviderTranscriptOrigin::DeveloperContext,
4459            ProviderTranscriptAuthor::Host,
4460            json!({
4461                "type":"additional_tools",
4462                "role":"developer",
4463                "tools":[{
4464                    "type":"function","name":"x","description":secret,
4465                    "parameters":{"type":"object"},"strict":false
4466                }]
4467            }),
4468        )
4469        .unwrap();
4470        assert!(!format!("{item:?}").contains(secret));
4471        let group = test_group(secret, vec![item]).unwrap();
4472        assert!(!format!("{group:?}").contains(secret));
4473
4474        let error = ProviderTranscriptItem::try_from_payload(
4475            ProviderFamily::OpenAi,
4476            ProviderProtocol::OpenAiResponsesV1,
4477            ProviderTranscriptOrigin::Provider,
4478            ProviderTranscriptAuthor::Model,
4479            json!({"type":secret}),
4480        )
4481        .unwrap_err();
4482        assert!(!format!("{error:?} {error}").contains(secret));
4483    }
4484}