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    pub byte_len: u64,
426}
427
428/// One conversation message: role and per-block metadata.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct MessageMeta {
431    pub role: MessageRole,
432    pub blocks: Vec<BlockMeta>,
433}
434
435/// One exposed tool: stable ID, wire name, schema size + keyed digest.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct ToolMeta {
438    pub stable_id: TraceId,
439    pub wire_name: WireName,
440    pub schema_byte_len: u64,
441    pub schema_digest: ComponentDigest,
442}
443
444/// Where a cache boundary marker sits in the request.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum CacheBoundaryLocation {
448    Tools,
449    System,
450    Messages,
451}
452
453/// Cache TTL class of one boundary.
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "snake_case")]
456pub enum CacheTtlClass {
457    FiveMinutes,
458    OneHour,
459}
460
461/// One declared cache boundary.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463pub struct CacheBoundaryMeta {
464    pub location: CacheBoundaryLocation,
465    /// Zero-based index of the element the boundary is attached to, within
466    /// its location (tool index, system segment index, or message index).
467    pub index: u32,
468    pub ttl: CacheTtlClass,
469}
470
471/// Stable-prefix metadata for cache diagnosis: bytes + keyed digest.
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct PrefixMeta {
474    pub byte_len: u64,
475    pub digest: ComponentDigest,
476}
477
478/// Previous-turn comparison state for one cache segment (spec §6.6).
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum SegmentChange {
482    /// Keyed digest identical to the previous emitted request in this
483    /// session — the provider can reuse its cached prefix.
484    Unchanged,
485    /// Keyed digest differs from the previous emitted request.
486    Changed,
487    /// No previous request in this session (or the segment appeared/vanished).
488    New,
489}
490
491/// Per-segment previous-turn change report plus changed-tool detail and
492/// reuse estimates (spec §6.6). Every field is bounded metadata: enums,
493/// validated tool IDs, and byte counts — never content. All fields are
494/// optional/defaulted so `synaps-request-trace/1` records written before
495/// this struct existed still deserialize.
496#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
497pub struct CacheSegmentDelta {
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub tools: Option<SegmentChange>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub system: Option<SegmentChange>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub history_tail: Option<SegmentChange>,
504    /// Stable IDs of tools whose schema digest changed, or that were added
505    /// or removed, relative to the previous emitted request.
506    #[serde(default, skip_serializing_if = "Vec::is_empty")]
507    pub changed_tool_ids: Vec<TraceId>,
508    /// True when the same tool set was sent in a different order — an
509    /// intentional-looking prefix invalidation.
510    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
511    pub tool_order_changed: bool,
512    /// Estimated bytes the provider can reuse from its cached prefix
513    /// (sum of unchanged-segment canonical byte lengths). An estimate over
514    /// canonical component bytes, not provider-internal token accounting.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub estimated_reused_bytes: Option<u64>,
517    /// Estimated bytes the provider must recompute (changed/new segments +
518    /// the history tail when it changed).
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub estimated_recomputed_bytes: Option<u64>,
521}
522
523/// Cache boundaries, stable-prefix digests, and previous-turn segment
524/// deltas (spec §6.6). The prefix/tail digests are keyed HMAC over
525/// **canonical component bytes** (see `trace::diagnostics` for the exact
526/// canonicalization and its documented approximations) — never a
527/// re-serialization passed off as exact wire bytes. All diagnostic fields
528/// are optional for backward-compatible deserialization.
529#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
530pub struct CacheMeta {
531    pub boundaries: Vec<CacheBoundaryMeta>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub tools_prefix: Option<PrefixMeta>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub system_prefix: Option<PrefixMeta>,
536    /// History tail: the canonical bytes of the messages *after* the last
537    /// message-level cache boundary (the segment the provider recomputes).
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub history_tail: Option<PrefixMeta>,
540    /// Previous-turn per-segment comparison, when a session snapshot was
541    /// available to compare against.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub delta: Option<CacheSegmentDelta>,
544}
545
546/// What a provider adapter did to a non-representable element.
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum TranslationAction {
550    Dropped,
551    Merged,
552    Renamed,
553    Synthesized,
554    Downgraded,
555    Unsupported,
556}
557
558/// Which structural element was affected.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum TranslationElement {
562    SystemSegment,
563    MessageBlock,
564    Tool,
565    Parameter,
566    Other,
567}
568
569/// One translation loss / synthetic rewrite: action + element reference only.
570/// The full `TranslationReport` (Task 9) will populate these.
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572pub struct TranslationLoss {
573    pub action: TranslationAction,
574    pub element: TranslationElement,
575    /// Stable identifier of the affected element **in the normalized
576    /// (pre-translation) request**: a tool stable ID, or a positional path
577    /// like `messages[3].blocks[1]` — never content.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub element_id: Option<TraceId>,
580}
581
582/// Coarse retry classification.
583#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(rename_all = "snake_case")]
585pub enum RetryClass {
586    RateLimited,
587    Overloaded,
588    ServerError,
589    Network,
590    Timeout,
591    Auth,
592    Other,
593}
594
595/// One retry: attempt ordinal, class, and delay before the next attempt.
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
597pub struct RetryMeta {
598    /// 1-based ordinal of the **failed** try this entry describes.
599    pub attempt: u32,
600    pub class: RetryClass,
601    /// Backoff delay before the next try, in milliseconds.
602    pub delay_ms: u64,
603}
604
605/// Timing stages (spec §6.4). Every stage is optional: a stage that was not
606/// observed is `None`, never a fabricated zero. Offsets are milliseconds from
607/// `send_start_unix_ms`.
608#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
609pub struct TimingStages {
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub send_start_unix_ms: Option<u64>,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub headers_ms: Option<u64>,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub first_byte_ms: Option<u64>,
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub first_model_event_ms: Option<u64>,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub stream_end_ms: Option<u64>,
620}
621
622/// Where a usage number came from.
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum UsageProvenance {
626    /// Reported verbatim by the provider in the response/stream.
627    ProviderReported,
628    /// Estimated locally (e.g. byte-based heuristics).
629    Estimated,
630}
631
632/// Token usage with provenance. Individual metrics the provider did not
633/// report are `None` — never zero-filled.
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
635pub struct UsageMeta {
636    pub provenance: UsageProvenance,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub input_tokens: Option<u64>,
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub output_tokens: Option<u64>,
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub cache_read_tokens: Option<u64>,
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub cache_write_tokens: Option<u64>,
645}
646
647/// Provider-assigned stop reason, normalized.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum StopReason {
651    EndTurn,
652    MaxTokens,
653    StopSequence,
654    ToolUse,
655    PauseTurn,
656    Refusal,
657    ContentFilter,
658    Other,
659}
660
661/// Common transport outcome (spec §6.4): every transport returns this shape.
662/// All metrics are optional — absent/unknown is `None`, never a fabricated
663/// zero. The terminal [`TurnOutcome`] is the single typed source of truth
664/// produced by the engine (spec §5.2).
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666pub struct TransportOutcome {
667    pub timings: TimingStages,
668    /// Transport-internal retries that preceded the final try of this
669    /// record: `retries.len() + 1` equals the envelope's top-level `attempt`.
670    pub retries: Vec<RetryMeta>,
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub provider_request_id: Option<TraceId>,
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub http_status: Option<u16>,
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub stop_reason: Option<StopReason>,
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub usage: Option<UsageMeta>,
679    pub terminal: TurnOutcome,
680}
681
682impl TransportOutcome {
683    /// An outcome with no observed metrics — everything `None`/empty except
684    /// the required terminal state.
685    pub fn unobserved(terminal: TurnOutcome) -> Self {
686        Self {
687            timings: TimingStages::default(),
688            retries: Vec::new(),
689            provider_request_id: None,
690            http_status: None,
691            stop_reason: None,
692            usage: None,
693            terminal,
694        }
695    }
696}
697
698// --- Correlated execution events (Task 27, spec §8.5) ---
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ExecutionPhase {
703    Planned,
704    Authorized,
705    Started,
706    Committed,
707    ResultRecorded,
708    Canceled,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "snake_case")]
713pub enum ExecutionEffect {
714    ReadOnly,
715    IdempotentWrite,
716    NonIdempotent,
717}
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(rename_all = "snake_case")]
721pub enum ExecutionCommitStatus {
722    NotStarted,
723    Started,
724    Committed,
725    ResultRecorded,
726    UnknownAfterSideEffect,
727    CanceledBeforeCommit,
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(tag = "kind", rename_all = "snake_case")]
732pub enum ActivationGrantRef {
733    Core,
734    Exact { catalog_generation: u64 },
735}
736
737/// One bounded, content-free lifecycle event. IDs and wire names use the
738/// validated trace types; result content is represented only by byte count,
739/// truncation metadata, and an optional bounded preview byte count.
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
741pub struct ToolExecutionEvent {
742    pub session_id: TraceId,
743    pub turn_id: TraceId,
744    pub request_id: TraceId,
745    pub tool_call_id: TraceId,
746    pub stable_tool_id: TraceId,
747    pub wire_name: WireName,
748    pub phase: ExecutionPhase,
749    pub elapsed_ms: u64,
750    pub result_bytes: u64,
751    pub truncated: bool,
752    pub preview_bytes: u64,
753    pub activation: ActivationGrantRef,
754    pub effect: ExecutionEffect,
755    pub commit_status: ExecutionCommitStatus,
756    /// Original model-request position; event assembly and returned
757    /// `tool_result` blocks both preserve this ordering.
758    pub model_order: u32,
759}
760
761// --- The envelope ---
762
763/// One `synaps-request-trace/1` record: metadata for a single request
764/// attempt. Field order is the canonical, deterministic serialization order.
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct RequestTrace {
767    /// Always `synaps-request-trace/1`; validated on read.
768    pub schema: TraceSchemaVersion,
769    pub session_id: TraceId,
770    pub turn_id: TraceId,
771    pub request_id: TraceId,
772    /// Correlated tool-execution lifecycle metadata observed while this
773    /// logical request's tool results were produced. Bounded metadata only.
774    #[serde(default, skip_serializing_if = "Vec::is_empty")]
775    pub execution_events: Vec<ToolExecutionEvent>,
776    /// 1-based try ordinal for this record: the number of tries the
777    /// transport made, i.e. `outcome.retries.len() + 1`.
778    pub attempt: u32,
779    /// Exact provider-qualified model identity (`provider/model`).
780    pub model: QualifiedModelId,
781    pub transport: TransportKind,
782    pub endpoint: EndpointMeta,
783    pub anatomy: RequestAnatomy,
784    /// Exact-wire metadata — populated by transports (Task 8) from the very
785    /// bytes sent; `None` until then.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub wire: Option<WireMeta>,
788    pub system_segments: Vec<SystemSegmentMeta>,
789    pub messages: Vec<MessageMeta>,
790    pub tools: Vec<ToolMeta>,
791    pub cache: CacheMeta,
792    pub translation_losses: Vec<TranslationLoss>,
793    pub outcome: TransportOutcome,
794}
795
796impl RequestTrace {
797    /// Provider segment of the qualified model identity.
798    pub fn provider(&self) -> &str {
799        self.model.provider()
800    }
801}