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}
561
562/// What a provider adapter did to a non-representable element.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum TranslationAction {
566    Dropped,
567    Merged,
568    Renamed,
569    Synthesized,
570    Downgraded,
571    Unsupported,
572}
573
574/// Which structural element was affected.
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum TranslationElement {
578    SystemSegment,
579    MessageBlock,
580    Tool,
581    Parameter,
582    Other,
583}
584
585/// One translation loss / synthetic rewrite: action + element reference only.
586/// The full `TranslationReport` (Task 9) will populate these.
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588pub struct TranslationLoss {
589    pub action: TranslationAction,
590    pub element: TranslationElement,
591    /// Stable identifier of the affected element **in the normalized
592    /// (pre-translation) request**: a tool stable ID, or a positional path
593    /// like `messages[3].blocks[1]` — never content.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub element_id: Option<TraceId>,
596}
597
598/// Coarse retry classification.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum RetryClass {
602    RateLimited,
603    Overloaded,
604    ServerError,
605    Network,
606    Timeout,
607    Auth,
608    Other,
609}
610
611/// One retry: attempt ordinal, class, and delay before the next attempt.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613pub struct RetryMeta {
614    /// 1-based ordinal of the **failed** try this entry describes.
615    pub attempt: u32,
616    pub class: RetryClass,
617    /// Backoff delay before the next try, in milliseconds.
618    pub delay_ms: u64,
619}
620
621/// Timing stages (spec §6.4). Every stage is optional: a stage that was not
622/// observed is `None`, never a fabricated zero. Offsets are milliseconds from
623/// `send_start_unix_ms`.
624#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
625pub struct TimingStages {
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub send_start_unix_ms: Option<u64>,
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub headers_ms: Option<u64>,
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub first_byte_ms: Option<u64>,
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub first_model_event_ms: Option<u64>,
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub stream_end_ms: Option<u64>,
636}
637
638/// Where a usage number came from.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641pub enum UsageProvenance {
642    /// Reported verbatim by the provider in the response/stream.
643    ProviderReported,
644    /// Estimated locally (e.g. byte-based heuristics).
645    Estimated,
646}
647
648/// Token usage with provenance. Individual metrics the provider did not
649/// report are `None` — never zero-filled.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651pub struct UsageMeta {
652    pub provenance: UsageProvenance,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub input_tokens: Option<u64>,
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub output_tokens: Option<u64>,
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub cache_read_tokens: Option<u64>,
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub cache_write_tokens: Option<u64>,
661}
662
663/// Provider-assigned stop reason, normalized.
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
665#[serde(rename_all = "snake_case")]
666pub enum StopReason {
667    EndTurn,
668    MaxTokens,
669    StopSequence,
670    ToolUse,
671    PauseTurn,
672    Refusal,
673    ContentFilter,
674    Other,
675}
676
677/// Common transport outcome (spec §6.4): every transport returns this shape.
678/// All metrics are optional — absent/unknown is `None`, never a fabricated
679/// zero. The terminal [`TurnOutcome`] is the single typed source of truth
680/// produced by the engine (spec §5.2).
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct TransportOutcome {
683    pub timings: TimingStages,
684    /// Transport-internal retries that preceded the final try of this
685    /// record: `retries.len() + 1` equals the envelope's top-level `attempt`.
686    pub retries: Vec<RetryMeta>,
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub provider_request_id: Option<TraceId>,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub http_status: Option<u16>,
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub stop_reason: Option<StopReason>,
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub usage: Option<UsageMeta>,
695    pub terminal: TurnOutcome,
696}
697
698impl TransportOutcome {
699    /// An outcome with no observed metrics — everything `None`/empty except
700    /// the required terminal state.
701    pub fn unobserved(terminal: TurnOutcome) -> Self {
702        Self {
703            timings: TimingStages::default(),
704            retries: Vec::new(),
705            provider_request_id: None,
706            http_status: None,
707            stop_reason: None,
708            usage: None,
709            terminal,
710        }
711    }
712}
713
714// --- Correlated execution events (Task 27, spec §8.5) ---
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum ExecutionPhase {
719    Planned,
720    Authorized,
721    Started,
722    Committed,
723    ResultRecorded,
724    Canceled,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
728#[serde(rename_all = "snake_case")]
729pub enum ExecutionEffect {
730    ReadOnly,
731    IdempotentWrite,
732    NonIdempotent,
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(rename_all = "snake_case")]
737pub enum ExecutionCommitStatus {
738    NotStarted,
739    Started,
740    Committed,
741    ResultRecorded,
742    UnknownAfterSideEffect,
743    CanceledBeforeCommit,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(tag = "kind", rename_all = "snake_case")]
748pub enum ActivationGrantRef {
749    Core,
750    Exact { catalog_generation: u64 },
751}
752
753/// One bounded, content-free lifecycle event. IDs and wire names use the
754/// validated trace types; result content is represented only by byte count,
755/// truncation metadata, and an optional bounded preview byte count.
756#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
757pub struct ToolExecutionEvent {
758    pub session_id: TraceId,
759    pub turn_id: TraceId,
760    pub request_id: TraceId,
761    pub tool_call_id: TraceId,
762    pub stable_tool_id: TraceId,
763    pub wire_name: WireName,
764    pub phase: ExecutionPhase,
765    pub elapsed_ms: u64,
766    pub result_bytes: u64,
767    pub truncated: bool,
768    pub preview_bytes: u64,
769    pub activation: ActivationGrantRef,
770    pub effect: ExecutionEffect,
771    pub commit_status: ExecutionCommitStatus,
772    /// Original model-request position; event assembly and returned
773    /// `tool_result` blocks both preserve this ordering.
774    pub model_order: u32,
775}
776
777// --- The envelope ---
778
779/// One `synaps-request-trace/1` record: metadata for a single request
780/// attempt. Field order is the canonical, deterministic serialization order.
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
782pub struct RequestTrace {
783    /// Always `synaps-request-trace/1`; validated on read.
784    pub schema: TraceSchemaVersion,
785    pub session_id: TraceId,
786    pub turn_id: TraceId,
787    pub request_id: TraceId,
788    /// Correlated tool-execution lifecycle metadata observed while this
789    /// logical request's tool results were produced. Bounded metadata only.
790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
791    pub execution_events: Vec<ToolExecutionEvent>,
792    /// 1-based try ordinal for this record: the number of tries the
793    /// transport made, i.e. `outcome.retries.len() + 1`.
794    pub attempt: u32,
795    /// Exact provider-qualified model identity (`provider/model`).
796    pub model: QualifiedModelId,
797    pub transport: TransportKind,
798    pub endpoint: EndpointMeta,
799    pub anatomy: RequestAnatomy,
800    /// Exact-wire metadata — populated by transports (Task 8) from the very
801    /// bytes sent; `None` until then.
802    #[serde(default, skip_serializing_if = "Option::is_none")]
803    pub wire: Option<WireMeta>,
804    pub system_segments: Vec<SystemSegmentMeta>,
805    pub messages: Vec<MessageMeta>,
806    pub tools: Vec<ToolMeta>,
807    pub cache: CacheMeta,
808    pub translation_losses: Vec<TranslationLoss>,
809    pub outcome: TransportOutcome,
810}
811
812impl RequestTrace {
813    /// Provider segment of the qualified model identity.
814    pub fn provider(&self) -> &str {
815        self.model.provider()
816    }
817}