Skip to main content

agent_engine/runtime/trace/
types.rs

1//! Envelope metadata types for `synaps-request-trace/1`.
2//!
3//! Every content-derived value is a count, byte length, bounded validated
4//! ID, enum, or keyed digest. See the module docs in `trace/mod.rs` for the
5//! full invariant statement.
6
7use super::key::ComponentDigest;
8use agent_core::prompt::QualifiedModelId;
9use agent_core::TurnOutcome;
10use serde::{Deserialize, Serialize};
11
12/// The exact version tag of this envelope schema.
13pub const TRACE_SCHEMA: &str = "synaps-request-trace/1";
14
15// --- Schema version tag ---
16
17/// Validated schema tag — serializes as the string `synaps-request-trace/1`
18/// and refuses to deserialize any other value, so a reader can never silently
19/// accept a record from a different (past or future) schema.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(try_from = "String", into = "String")]
22pub struct TraceSchemaVersion;
23
24impl TryFrom<String> for TraceSchemaVersion {
25    type Error = String;
26    fn try_from(value: String) -> Result<Self, Self::Error> {
27        if value == TRACE_SCHEMA {
28            Ok(TraceSchemaVersion)
29        } else {
30            Err(format!(
31                "unsupported trace schema tag (expected {TRACE_SCHEMA})"
32            ))
33        }
34    }
35}
36
37impl From<TraceSchemaVersion> for String {
38    fn from(_: TraceSchemaVersion) -> Self {
39        TRACE_SCHEMA.to_string()
40    }
41}
42
43// --- Validated bounded identifiers ---
44
45/// Maximum byte length of a [`TraceId`].
46pub const TRACE_ID_MAX_BYTES: usize = 256;
47
48/// Maximum byte length of a [`WireName`].
49pub const WIRE_NAME_MAX_BYTES: usize = 128;
50
51fn validate_trace_id(s: &str) -> Result<(), String> {
52    if s.is_empty() {
53        return Err("trace id must be nonempty".to_string());
54    }
55    if s.len() > TRACE_ID_MAX_BYTES {
56        return Err(format!(
57            "trace id exceeds {TRACE_ID_MAX_BYTES} bytes ({} given)",
58            s.len()
59        ));
60    }
61    for b in s.bytes() {
62        let ok = b.is_ascii_alphanumeric()
63            || matches!(b, b'-' | b'_' | b'.' | b'/' | b':' | b'[' | b']');
64        if !ok {
65            return Err(format!(
66                "trace id contains forbidden byte 0x{b:02x} (allowed: [A-Za-z0-9._/:\\[\\]-])"
67            ));
68        }
69    }
70    Ok(())
71}
72
73fn validate_wire_name(s: &str) -> Result<(), String> {
74    if s.is_empty() {
75        return Err("wire name must be nonempty".to_string());
76    }
77    if s.len() > WIRE_NAME_MAX_BYTES {
78        return Err(format!(
79            "wire name exceeds {WIRE_NAME_MAX_BYTES} bytes ({} given)",
80            s.len()
81        ));
82    }
83    for b in s.bytes() {
84        if !(b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
85            return Err(format!(
86                "wire name contains forbidden byte 0x{b:02x} (allowed: [A-Za-z0-9_-])"
87            ));
88        }
89    }
90    Ok(())
91}
92
93/// Bounded, safe-alphabet identifier — serialized as a plain JSON string.
94///
95/// Invariant: nonempty, at most [`TRACE_ID_MAX_BYTES`] bytes, ASCII from
96/// `[A-Za-z0-9._/:\[\]-]` only (the `[`/`]` support positional paths like
97/// `messages[3].blocks[1]`). Whitespace, control chars, quotes, backslashes
98/// and non-ASCII are rejected on both construction and deserialization.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(try_from = "String", into = "String")]
101pub struct TraceId(String);
102
103impl TraceId {
104    /// Validated constructor.
105    pub fn new(value: impl Into<String>) -> Result<Self, String> {
106        let value = value.into();
107        validate_trace_id(&value)?;
108        Ok(TraceId(value))
109    }
110
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114}
115
116impl std::fmt::Display for TraceId {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(&self.0)
119    }
120}
121
122impl TryFrom<String> for TraceId {
123    type Error = String;
124    fn try_from(value: String) -> Result<Self, Self::Error> {
125        TraceId::new(value)
126    }
127}
128
129impl TryFrom<&str> for TraceId {
130    type Error = String;
131    fn try_from(value: &str) -> Result<Self, Self::Error> {
132        TraceId::new(value)
133    }
134}
135
136impl From<TraceId> for String {
137    fn from(value: TraceId) -> Self {
138        value.0
139    }
140}
141
142/// Wire-level tool name — stricter than [`TraceId`]: nonempty, at most
143/// [`WIRE_NAME_MAX_BYTES`] bytes, `[A-Za-z0-9_-]` only (the intersection of
144/// provider tool-name grammars). Serialized as a plain JSON string.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(try_from = "String", into = "String")]
147pub struct WireName(String);
148
149impl WireName {
150    /// Validated constructor.
151    pub fn new(value: impl Into<String>) -> Result<Self, String> {
152        let value = value.into();
153        validate_wire_name(&value)?;
154        Ok(WireName(value))
155    }
156
157    pub fn as_str(&self) -> &str {
158        &self.0
159    }
160}
161
162impl std::fmt::Display for WireName {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        f.write_str(&self.0)
165    }
166}
167
168impl TryFrom<String> for WireName {
169    type Error = String;
170    fn try_from(value: String) -> Result<Self, Self::Error> {
171        WireName::new(value)
172    }
173}
174
175impl TryFrom<&str> for WireName {
176    type Error = String;
177    fn try_from(value: &str) -> Result<Self, Self::Error> {
178        WireName::new(value)
179    }
180}
181
182impl From<WireName> for String {
183    fn from(value: WireName) -> Self {
184        value.0
185    }
186}
187
188// --- Envelope metadata types (no content-bearing fields) ---
189
190/// Which wire protocol carried the request.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum TransportKind {
194    AnthropicMessages,
195    OpenAiChatCompletions,
196    OpenAiResponses,
197    GeminiGenerateContent,
198    VertexGenerateContent,
199    CloudProxy,
200    Extension,
201}
202
203/// Maximum byte length of an endpoint host (RFC 1035 name ceiling + port).
204const ENDPOINT_HOST_MAX_BYTES: usize = 262;
205
206/// Maximum byte length of an endpoint path.
207const ENDPOINT_PATH_MAX_BYTES: usize = 1024;
208
209fn validate_endpoint_host(s: &str) -> Result<(), String> {
210    if s.is_empty() {
211        return Err("endpoint host must be nonempty".to_string());
212    }
213    if s.len() > ENDPOINT_HOST_MAX_BYTES {
214        return Err("endpoint host too long".to_string());
215    }
216    // No control chars, whitespace, non-ASCII, userinfo, query, fragment,
217    // or path separators anywhere in the host.
218    for b in s.bytes() {
219        if !(0x21..=0x7e).contains(&b) {
220            return Err("endpoint host contains whitespace/control/non-ASCII".to_string());
221        }
222        if matches!(b, b'@' | b'?' | b'#' | b'/' | b'\\' | b'"' | b'\'') {
223            return Err(format!(
224                "endpoint host contains forbidden char {:?}",
225                b as char
226            ));
227        }
228    }
229    fn validate_port(p: &str) -> Result<(), String> {
230        if p.is_empty() || p.len() > 5 || !p.bytes().all(|b| b.is_ascii_digit()) {
231            return Err("endpoint host port must be 1-5 digits".to_string());
232        }
233        Ok(())
234    }
235    if let Some(rest) = s.strip_prefix('[') {
236        // Bracketed IPv6 literal, optionally with a numeric port.
237        let end = rest
238            .find(']')
239            .ok_or_else(|| "unterminated bracketed IPv6 host".to_string())?;
240        let inner = &rest[..end];
241        if inner.is_empty()
242            || !inner
243                .bytes()
244                .all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.')
245        {
246            return Err("invalid bracketed IPv6 host".to_string());
247        }
248        let after = &rest[end + 1..];
249        if !after.is_empty() {
250            let port = after
251                .strip_prefix(':')
252                .ok_or_else(|| "unexpected trailing bytes after IPv6 bracket".to_string())?;
253            validate_port(port)?;
254        }
255        return Ok(());
256    }
257    // DNS name or IPv4 literal, optionally with a numeric port. At most one
258    // colon, and only as the port separator.
259    let (name, port) = match s.split_once(':') {
260        Some((name, port)) => {
261            if port.contains(':') {
262                return Err("endpoint host contains multiple colons".to_string());
263            }
264            (name, Some(port))
265        }
266        None => (s, None),
267    };
268    if name.is_empty()
269        || !name
270            .bytes()
271            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
272    {
273        return Err("endpoint host name must be [A-Za-z0-9.-]".to_string());
274    }
275    if let Some(port) = port {
276        validate_port(port)?;
277    }
278    Ok(())
279}
280
281fn validate_endpoint_path(s: &str) -> Result<(), String> {
282    if s.is_empty() {
283        return Err("endpoint path must be nonempty".to_string());
284    }
285    if !s.starts_with('/') {
286        return Err("endpoint path must begin with '/'".to_string());
287    }
288    if s.len() > ENDPOINT_PATH_MAX_BYTES {
289        return Err("endpoint path too long".to_string());
290    }
291    for b in s.bytes() {
292        let ok = b.is_ascii_alphanumeric()
293            || matches!(
294                b,
295                b'/' | b'-' | b'_' | b'.' | b'~' | b'%' | b':' | b'=' | b'+' | b','
296            );
297        if !ok {
298            return Err(format!(
299                "endpoint path contains forbidden byte 0x{b:02x} (no query/fragment/whitespace)"
300            ));
301        }
302    }
303    Ok(())
304}
305
306/// Serde shadow for [`EndpointMeta`] — keeps the wire shape `{host, path}`
307/// while routing every read through validation.
308#[derive(Serialize, Deserialize)]
309struct RawEndpoint {
310    host: String,
311    path: String,
312}
313
314/// Endpoint identity — validated host and path only; never query strings,
315/// fragments, userinfo, or headers. Fields are private: construction is only
316/// via [`EndpointMeta::new`] (or deserialization, which applies the same
317/// validation). Serializes as `{host, path}`.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(try_from = "RawEndpoint", into = "RawEndpoint")]
320pub struct EndpointMeta {
321    host: String,
322    path: String,
323}
324
325impl EndpointMeta {
326    /// Validated constructor. `host` may be a DNS name, an IPv4 literal, or a
327    /// bracketed IPv6 literal, each optionally with a numeric `:port` (for
328    /// local fixtures/proxies). `path` must begin with `/`. Query strings
329    /// (`?`), fragments (`#`), userinfo (`@`), whitespace and control chars
330    /// are rejected outright.
331    pub fn new(host: impl Into<String>, path: impl Into<String>) -> Result<Self, String> {
332        let host = host.into();
333        let path = path.into();
334        validate_endpoint_host(&host)?;
335        validate_endpoint_path(&path)?;
336        Ok(EndpointMeta { host, path })
337    }
338
339    pub fn host(&self) -> &str {
340        &self.host
341    }
342
343    pub fn path(&self) -> &str {
344        &self.path
345    }
346}
347
348impl TryFrom<RawEndpoint> for EndpointMeta {
349    type Error = String;
350    fn try_from(value: RawEndpoint) -> Result<Self, Self::Error> {
351        EndpointMeta::new(value.host, value.path)
352    }
353}
354
355impl From<EndpointMeta> for RawEndpoint {
356    fn from(value: EndpointMeta) -> Self {
357        RawEndpoint {
358            host: value.host,
359            path: value.path,
360        }
361    }
362}
363
364/// Normalized request anatomy: counts only.
365#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
366pub struct RequestAnatomy {
367    pub system_segment_count: u32,
368    pub message_count: u32,
369    pub block_count: u32,
370    pub tool_count: u32,
371}
372
373/// Exact-wire metadata, computed from the very bytes handed to the transport
374/// (Task 8). `None` until a transport populates it — never re-serialized.
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct WireMeta {
377    pub byte_len: u64,
378    pub digest: ComponentDigest,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum SystemSegmentKind {
384    Primary,
385    Orchestration,
386    Memory,
387    Skill,
388    Other,
389}
390
391/// One system segment: type, size, keyed digest. No text.
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct SystemSegmentMeta {
394    pub kind: SystemSegmentKind,
395    pub byte_len: u64,
396    pub digest: ComponentDigest,
397}
398
399/// Message author role.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum MessageRole {
403    User,
404    Assistant,
405    System,
406    Tool,
407}
408
409/// Content-block category.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum BlockKind {
413    Text,
414    Thinking,
415    ToolUse,
416    ToolResult,
417    Media,
418    Unknown,
419}
420
421/// One block within a message: kind + size only.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423pub struct BlockMeta {
424    pub kind: BlockKind,
425    /// Size of the block as sent. The unit depends on the message's content
426    /// shape (the Anthropic builder, `trace::anthropic::message_meta`):
427    ///
428    /// - string `content`: one synthetic `text` block whose `byte_len` is the
429    ///   RAW UTF-8 length of the string, with no JSON framing;
430    /// - array `content`: per block, the length of the block's compact JSON
431    ///   serialization, INCLUDING `{"type":"text","text":…}` framing and any
432    ///   `cache_control` marker (a 1h marker adds the 48 bytes of
433    ///   `,"cache_control":{"type":"ephemeral","ttl":"1h"}`).
434    ///
435    /// So the same user text measures differently on the request where it is
436    /// the newest message (coerced to blocks and marked) and on later
437    /// requests (bare string history). For example, `"yo, start steam in bg "`
438    /// is 22 as history and 95 as the marked tail block (47 for the framed
439    /// block plus 48 for the marker). That is not decoration. Compare sizes
440    /// only within one content shape.
441    pub byte_len: u64,
442}
443
444/// One conversation message: role and per-block metadata.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446pub struct MessageMeta {
447    pub role: MessageRole,
448    pub blocks: Vec<BlockMeta>,
449}
450
451/// One exposed tool: stable ID, wire name, schema size + keyed digest.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct ToolMeta {
454    pub stable_id: TraceId,
455    pub wire_name: WireName,
456    pub schema_byte_len: u64,
457    pub schema_digest: ComponentDigest,
458}
459
460/// Where a cache boundary marker sits in the request.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "snake_case")]
463pub enum CacheBoundaryLocation {
464    Tools,
465    System,
466    Messages,
467}
468
469/// Cache TTL class of one boundary.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum CacheTtlClass {
473    FiveMinutes,
474    OneHour,
475}
476
477/// One declared cache boundary.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
479pub struct CacheBoundaryMeta {
480    pub location: CacheBoundaryLocation,
481    /// Zero-based index of the element the boundary is attached to, within
482    /// its location (tool index, system segment index, or message index).
483    pub index: u32,
484    pub ttl: CacheTtlClass,
485}
486
487/// Stable-prefix metadata for cache diagnosis: bytes + keyed digest.
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub struct PrefixMeta {
490    pub byte_len: u64,
491    pub digest: ComponentDigest,
492}
493
494/// Previous-turn comparison state for one cache segment (spec §6.6).
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum SegmentChange {
498    /// Keyed digest identical to the previous emitted request in this
499    /// session — the provider can reuse its cached prefix.
500    Unchanged,
501    /// Keyed digest differs from the previous emitted request.
502    Changed,
503    /// No previous request in this session (or the segment appeared/vanished).
504    New,
505}
506
507/// Per-segment previous-turn change report plus changed-tool detail and
508/// reuse estimates (spec §6.6). Every field is bounded metadata: enums,
509/// validated tool IDs, and byte counts — never content. All fields are
510/// optional/defaulted so `synaps-request-trace/1` records written before
511/// this struct existed still deserialize.
512#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
513pub struct CacheSegmentDelta {
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub tools: Option<SegmentChange>,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub system: Option<SegmentChange>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub history_tail: Option<SegmentChange>,
520    /// Stable IDs of tools whose schema digest changed, or that were added
521    /// or removed, relative to the previous emitted request.
522    #[serde(default, skip_serializing_if = "Vec::is_empty")]
523    pub changed_tool_ids: Vec<TraceId>,
524    /// True when the same tool set was sent in a different order — an
525    /// intentional-looking prefix invalidation.
526    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
527    pub tool_order_changed: bool,
528    /// Estimated bytes the provider can reuse from its cached prefix
529    /// (sum of unchanged-segment canonical byte lengths). An estimate over
530    /// canonical component bytes, not provider-internal token accounting.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub estimated_reused_bytes: Option<u64>,
533    /// Estimated bytes the provider must recompute (changed/new segments +
534    /// the history tail when it changed).
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub estimated_recomputed_bytes: Option<u64>,
537}
538
539/// Cache boundaries, stable-prefix digests, and previous-turn segment
540/// deltas (spec §6.6). The prefix/tail digests are keyed HMAC over
541/// **canonical component bytes** (see `trace::diagnostics` for the exact
542/// canonicalization and its documented approximations) — never a
543/// re-serialization passed off as exact wire bytes. All diagnostic fields
544/// are optional for backward-compatible deserialization.
545#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
546pub struct CacheMeta {
547    pub boundaries: Vec<CacheBoundaryMeta>,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub tools_prefix: Option<PrefixMeta>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub system_prefix: Option<PrefixMeta>,
552    /// History tail: the canonical bytes of the messages *after* the last
553    /// message-level cache boundary (the segment the provider recomputes).
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub history_tail: Option<PrefixMeta>,
556    /// Previous-turn per-segment comparison, when a session snapshot was
557    /// available to compare against.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub delta: Option<CacheSegmentDelta>,
560    /// The provider's own verdict (`cache_diagnostics = true`, Anthropic
561    /// only): where this request first diverged from the previous one in
562    /// the chain. Absent when diagnostics is off or no divergence was found.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub server_diagnosis: Option<ServerCacheDiagnosis>,
565}
566
567/// `cache_miss_reason.type` from Anthropic cache diagnostics, normalized to
568/// a closed set (provider text is never copied into a record).
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(rename_all = "snake_case")]
571pub enum CacheMissReason {
572    ModelChanged,
573    SystemChanged,
574    ToolsChanged,
575    MessagesChanged,
576    PreviousMessageNotFound,
577    Unavailable,
578    /// `{"cache_miss_reason": null}`: the comparison was still running.
579    Pending,
580    /// A reason type this build does not know (on the wire, or written by
581    /// a newer build).
582    #[serde(other)]
583    Other,
584}
585
586impl CacheMissReason {
587    pub fn from_wire(s: &str) -> Self {
588        match s {
589            "model_changed" => Self::ModelChanged,
590            "system_changed" => Self::SystemChanged,
591            "tools_changed" => Self::ToolsChanged,
592            "messages_changed" => Self::MessagesChanged,
593            "previous_message_not_found" => Self::PreviousMessageNotFound,
594            "unavailable" => Self::Unavailable,
595            _ => Self::Other,
596        }
597    }
598
599    pub fn as_str(self) -> &'static str {
600        match self {
601            Self::ModelChanged => "model_changed",
602            Self::SystemChanged => "system_changed",
603            Self::ToolsChanged => "tools_changed",
604            Self::MessagesChanged => "messages_changed",
605            Self::PreviousMessageNotFound => "previous_message_not_found",
606            Self::Unavailable => "unavailable",
607            Self::Pending => "pending",
608            Self::Other => "other",
609        }
610    }
611}
612
613/// Provider-side cache diagnosis for one request.
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
615pub struct ServerCacheDiagnosis {
616    pub reason: CacheMissReason,
617    /// `cache_missed_input_tokens`: the provider's estimate of the input
618    /// after the divergence point (`*_changed` reasons only).
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub missed_input_tokens: Option<u64>,
621}
622
623/// What a provider adapter did to a non-representable element.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(rename_all = "snake_case")]
626pub enum TranslationAction {
627    Dropped,
628    Merged,
629    Renamed,
630    Synthesized,
631    Downgraded,
632    Unsupported,
633}
634
635/// Which structural element was affected.
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "snake_case")]
638pub enum TranslationElement {
639    SystemSegment,
640    MessageBlock,
641    Tool,
642    Parameter,
643    Other,
644}
645
646/// One translation loss / synthetic rewrite: action + element reference only.
647/// The full `TranslationReport` (Task 9) will populate these.
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct TranslationLoss {
650    pub action: TranslationAction,
651    pub element: TranslationElement,
652    /// Stable identifier of the affected element **in the normalized
653    /// (pre-translation) request**: a tool stable ID, or a positional path
654    /// like `messages[3].blocks[1]` — never content.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub element_id: Option<TraceId>,
657}
658
659/// Coarse retry classification.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum RetryClass {
663    RateLimited,
664    Overloaded,
665    ServerError,
666    Network,
667    Timeout,
668    Auth,
669    Other,
670}
671
672/// One retry: attempt ordinal, class, and delay before the next attempt.
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
674pub struct RetryMeta {
675    /// 1-based ordinal of the **failed** try this entry describes.
676    pub attempt: u32,
677    pub class: RetryClass,
678    /// Backoff delay before the next try, in milliseconds.
679    pub delay_ms: u64,
680}
681
682/// Timing stages (spec §6.4). Every stage is optional: a stage that was not
683/// observed is `None`, never a fabricated zero. Offsets are milliseconds from
684/// `send_start_unix_ms`.
685#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
686pub struct TimingStages {
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub send_start_unix_ms: Option<u64>,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub headers_ms: Option<u64>,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub first_byte_ms: Option<u64>,
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub first_model_event_ms: Option<u64>,
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub stream_end_ms: Option<u64>,
697}
698
699/// Where a usage number came from.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum UsageProvenance {
703    /// Reported verbatim by the provider in the response/stream.
704    ProviderReported,
705    /// Estimated locally (e.g. byte-based heuristics).
706    Estimated,
707}
708
709/// Token usage with provenance. Individual metrics the provider did not
710/// report are `None` — never zero-filled.
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712pub struct UsageMeta {
713    pub provenance: UsageProvenance,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub input_tokens: Option<u64>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub output_tokens: Option<u64>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub cache_read_tokens: Option<u64>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub cache_write_tokens: Option<u64>,
722}
723
724/// Provider-assigned stop reason, normalized.
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
726#[serde(rename_all = "snake_case")]
727pub enum StopReason {
728    EndTurn,
729    MaxTokens,
730    StopSequence,
731    ToolUse,
732    PauseTurn,
733    Refusal,
734    ContentFilter,
735    Other,
736}
737
738/// Common transport outcome (spec §6.4): every transport returns this shape.
739/// All metrics are optional — absent/unknown is `None`, never a fabricated
740/// zero. The terminal [`TurnOutcome`] is the single typed source of truth
741/// produced by the engine (spec §5.2).
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
743pub struct TransportOutcome {
744    pub timings: TimingStages,
745    /// Transport-internal retries that preceded the final try of this
746    /// record: `retries.len() + 1` equals the envelope's top-level `attempt`.
747    pub retries: Vec<RetryMeta>,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub provider_request_id: Option<TraceId>,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub http_status: Option<u16>,
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub stop_reason: Option<StopReason>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub usage: Option<UsageMeta>,
756    pub terminal: TurnOutcome,
757}
758
759impl TransportOutcome {
760    /// An outcome with no observed metrics — everything `None`/empty except
761    /// the required terminal state.
762    pub fn unobserved(terminal: TurnOutcome) -> Self {
763        Self {
764            timings: TimingStages::default(),
765            retries: Vec::new(),
766            provider_request_id: None,
767            http_status: None,
768            stop_reason: None,
769            usage: None,
770            terminal,
771        }
772    }
773}
774
775// --- Correlated execution events (Task 27, spec §8.5) ---
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(rename_all = "snake_case")]
779pub enum ExecutionPhase {
780    Planned,
781    Authorized,
782    Started,
783    Committed,
784    ResultRecorded,
785    Canceled,
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(rename_all = "snake_case")]
790pub enum ExecutionEffect {
791    ReadOnly,
792    IdempotentWrite,
793    NonIdempotent,
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
797#[serde(rename_all = "snake_case")]
798pub enum ExecutionCommitStatus {
799    NotStarted,
800    Started,
801    Committed,
802    ResultRecorded,
803    UnknownAfterSideEffect,
804    CanceledBeforeCommit,
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(tag = "kind", rename_all = "snake_case")]
809pub enum ActivationGrantRef {
810    Core,
811    Exact { catalog_generation: u64 },
812}
813
814/// One bounded, content-free lifecycle event. IDs and wire names use the
815/// validated trace types; result content is represented only by byte count,
816/// truncation metadata, and an optional bounded preview byte count.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818pub struct ToolExecutionEvent {
819    pub session_id: TraceId,
820    pub turn_id: TraceId,
821    pub request_id: TraceId,
822    pub tool_call_id: TraceId,
823    pub stable_tool_id: TraceId,
824    pub wire_name: WireName,
825    pub phase: ExecutionPhase,
826    pub elapsed_ms: u64,
827    pub result_bytes: u64,
828    pub truncated: bool,
829    pub preview_bytes: u64,
830    pub activation: ActivationGrantRef,
831    pub effect: ExecutionEffect,
832    pub commit_status: ExecutionCommitStatus,
833    /// Original model-request position; event assembly and returned
834    /// `tool_result` blocks both preserve this ordering.
835    pub model_order: u32,
836}
837
838// --- The envelope ---
839
840/// One `synaps-request-trace/1` record: metadata for a single request
841/// attempt. Field order is the canonical, deterministic serialization order.
842#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
843pub struct RequestTrace {
844    /// Always `synaps-request-trace/1`; validated on read.
845    pub schema: TraceSchemaVersion,
846    pub session_id: TraceId,
847    pub turn_id: TraceId,
848    pub request_id: TraceId,
849    /// Correlated tool-execution lifecycle metadata observed while this
850    /// logical request's tool results were produced. Bounded metadata only.
851    #[serde(default, skip_serializing_if = "Vec::is_empty")]
852    pub execution_events: Vec<ToolExecutionEvent>,
853    /// 1-based try ordinal for this record: the number of tries the
854    /// transport made, i.e. `outcome.retries.len() + 1`.
855    pub attempt: u32,
856    /// Exact provider-qualified model identity (`provider/model`).
857    pub model: QualifiedModelId,
858    pub transport: TransportKind,
859    pub endpoint: EndpointMeta,
860    pub anatomy: RequestAnatomy,
861    /// Exact-wire metadata — populated by transports (Task 8) from the very
862    /// bytes sent; `None` until then.
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub wire: Option<WireMeta>,
865    pub system_segments: Vec<SystemSegmentMeta>,
866    pub messages: Vec<MessageMeta>,
867    pub tools: Vec<ToolMeta>,
868    pub cache: CacheMeta,
869    pub translation_losses: Vec<TranslationLoss>,
870    pub outcome: TransportOutcome,
871}
872
873impl RequestTrace {
874    /// Provider segment of the qualified model identity.
875    pub fn provider(&self) -> &str {
876        self.model.provider()
877    }
878}