Skip to main content

ratel_ai_core/trace/
event.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use crate::{Fact, Skill, Tool};
5
6const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
7
8/// Catalog entry type carried by [`TraceEvent::CatalogDefinition`].
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CatalogKind {
12    /// An executable tool definition.
13    Tool,
14    /// An on-demand skill definition.
15    Skill,
16    /// A grounding fact definition.
17    Fact,
18}
19
20#[derive(Serialize)]
21struct CatalogDefinitionContent<'a> {
22    kind: CatalogKind,
23    id: &'a str,
24    name: &'a str,
25    description: &'a str,
26    tags: &'a [String],
27    input_schema: Option<&'a serde_json::Value>,
28    output_schema: Option<&'a serde_json::Value>,
29    searchable_description: &'a str,
30    searchable_description_overridden: bool,
31}
32
33/// Where a search came from. Trace consumers separate the paths: rerankers
34/// train on agent calls, the inspector shows all of them, and offline graph
35/// construction reads only the baseline ones.
36///
37/// **Non-exhaustive**, for the same reason [`TraceEvent`] is: the set of
38/// origins grows as Ratel learns to sit in more places, and a downstream
39/// `match` acquiring a `_ =>` arm once is cheaper than a breaking release per
40/// variant. Constructing existing variants is unaffected; only exhaustive
41/// matches need the arm.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum Origin {
46    /// A direct API call — SDK helpers, library callers, benchmarks. Wire
47    /// value `direct`.
48    Direct,
49    /// A call the agent synthesized inside its loop, via the capability
50    /// tools. Wire value `agent`.
51    Agent,
52    /// A query recorded while Ratel was **observing but not serving**: the host
53    /// captured the turn's text so the invocations that follow can be
54    /// attributed to it, while the agent chose from its own full tool list.
55    /// Wire value `baseline`.
56    ///
57    /// Ratel's own search path never produces this — it is written by a host
58    /// running a baseline capture, and it is what marks an observation as
59    /// unbiased evidence rather than something the ranker influenced.
60    Baseline,
61}
62
63/// How a registry corpus changed — carried by [`TraceEvent::IndexChurn`]
64/// (tools), [`TraceEvent::SkillChurn`] (skills), and [`TraceEvent::FactChurn`]
65/// (facts).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum ChurnKind {
69    /// An item was registered — including a replace-in-place re-register of
70    /// an existing id. Wire value `add`.
71    Add,
72    /// An item was removed from the corpus. Wire value `remove`.
73    Remove,
74}
75
76/// Outcome of the one-time embedding-model load. `Slow` flags a machine that may
77/// be underpowered for the model; `Failed` a load that errored (network, cache,
78/// corrupt weights).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum EmbedderLoadStatus {
82    /// The model loaded within the expected budget. Wire value `ok`.
83    Ok,
84    /// The model loaded, but slowly — the machine may be underpowered for it.
85    /// Wire value `slow`.
86    Slow,
87    /// The load errored (network, cache, corrupt weights); the accompanying
88    /// `reason` carries the error. Wire value `failed`.
89    Failed,
90}
91
92/// One ranked tool hit inside a [`TraceEvent::Search`] event.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct SearchHitTrace {
95    /// Id of the matching tool.
96    pub tool_id: String,
97    /// The engine score, widened to `f64` — same per-method semantics as
98    /// [`crate::SearchHit::score`].
99    pub score: f64,
100}
101
102/// Timing and top score of one engine stage of a search. BM25 searches emit
103/// one `bm25` stage, semantic searches one `dense` stage; hybrid emits
104/// `bm25`, `dense`, and `rrf`, in that order. Semantic and hybrid searches
105/// that short-circuit on an empty corpus or `top_k == 0` emit no stages.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct SearchStage {
108    /// Stage name: `"bm25"`, `"dense"`, or `"rrf"`.
109    pub name: String,
110    /// Stage wall time, in milliseconds.
111    pub took_ms: u64,
112    /// Best score the stage produced (that stage's scale); `None` when it
113    /// returned no hits.
114    pub top_score: Option<f64>,
115}
116
117/// One ranked skill hit inside a [`TraceEvent::SkillSearch`] event — the
118/// skill-side twin of [`SearchHitTrace`].
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct SkillHitTrace {
121    /// Id of the matching skill.
122    pub skill_id: String,
123    /// The engine score, widened to `f64` — same per-method semantics as
124    /// [`crate::SkillHit::score`].
125    pub score: f64,
126}
127
128/// One ranked fact hit inside a [`TraceEvent::FactSearch`] event — the
129/// fact-side twin of [`SkillHitTrace`].
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct FactHitTrace {
132    /// Id of the matching fact.
133    pub fact_id: String,
134    /// The engine score, widened to `f64` — same per-method semantics as
135    /// [`crate::FactHit::score`].
136    pub score: f64,
137}
138
139/// Why a fact's body was (re-)injected into the context, carried by
140/// [`TraceEvent::FactInject`]. The grounding layer decides this by scanning
141/// the transcript for the fact's own body text (content presence); it is the
142/// observable half of the re-injection freshness gate.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum FactInjectReason {
146    /// Not present in the transcript and never injected this session — a
147    /// first injection. Wire value `never`.
148    Never,
149    /// Injected earlier but its body is gone from the window now (trimmed /
150    /// compacted out), so it is re-injected. Wire value `evicted`.
151    Evicted,
152    /// The registered body changed since it was injected (the current body is
153    /// absent and differs from the one last injected), so the new version is
154    /// injected. Wire value `mutated`.
155    Mutated,
156}
157
158/// Every event produced by any layer of Ratel. New variants are additive;
159/// renames or removals are breaking — see ADR-0007.
160///
161/// On the wire each event is a JSON object whose `type` tag is the variant
162/// name in snake_case (`IndexChurn` → `index_churn`), with the variant's
163/// fields flattened beside it; sinks wrap it in a [`TraceEnvelope`]. All
164/// `took_ms` fields are wall time in milliseconds.
165///
166/// `#[non_exhaustive]` is what makes "new variants are additive" *true* rather
167/// than aspirational: it requires downstream `match`es to carry a `_ =>` arm, so
168/// a future event variant lands there instead of breaking their compile. Two
169/// axes, only the first mechanical:
170///
171/// - **New variant** → non-breaking, enforced here.
172/// - **New field on an existing variant** → non-breaking only if consumers
173///   destructure with a trailing `..` (as this crate always does); variant-level
174///   non-exhaustiveness is intentionally *not* used, since it would also block
175///   downstream from constructing events by literal.
176///
177/// Renames and removals are breaking on both axes.
178///
179/// ```
180/// use ratel_ai_core::TraceEvent;
181/// // A downstream matcher must include `_ =>`, and is then future-proof:
182/// fn kind(e: &TraceEvent) -> &str {
183///     match e {
184///         TraceEvent::Search { .. } => "search",
185///         _ => "other",
186///     }
187/// }
188/// ```
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191#[non_exhaustive]
192pub enum TraceEvent {
193    /// A catalog definition was registered or materially changed. Content is
194    /// present on the local stream; SDKs project it to the opt-in
195    /// `ratel.catalog.definition` Logs EventRecord.
196    CatalogDefinition {
197        /// Catalog entry type.
198        kind: CatalogKind,
199        /// Stable catalog entry id.
200        id: String,
201        /// Model-facing display/callable name.
202        name: String,
203        /// Model-facing description.
204        description: String,
205        /// Search tags; empty for tools.
206        tags: Vec<String>,
207        /// Tool input JSON Schema; absent for skills and facts.
208        input_schema: Option<Box<serde_json::Value>>,
209        /// Tool output JSON Schema; absent for skills and facts.
210        output_schema: Option<Box<serde_json::Value>>,
211        /// Effective searchable description after applying the optional override.
212        searchable_description: String,
213        /// Whether the effective searchable description came from an override.
214        searchable_description_overridden: bool,
215        /// Lowercase SHA-256 of the definition fields above.
216        content_hash: String,
217    },
218    /// A [`crate::ToolRegistry`] search completed (any [`crate::SearchMethod`]).
219    /// Carries the query, the requested `top_k`, the ranked `hits` with
220    /// scores, the per-engine `stages` timings, and the total wall time.
221    Search {
222        /// The search text.
223        query: String,
224        /// Direct library call vs agent-synthesized.
225        origin: Origin,
226        /// Requested result count.
227        top_k: u32,
228        /// The ranked results, best-first.
229        hits: Vec<SearchHitTrace>,
230        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
231        stages: Vec<SearchStage>,
232        /// Total search wall time, in milliseconds.
233        took_ms: u64,
234    },
235    /// The tool corpus changed: [`crate::ToolRegistry::register`] emits this
236    /// with [`ChurnKind::Add`] for both a fresh registration and a
237    /// replace-in-place re-register.
238    IndexChurn {
239        /// Whether the id was added or removed.
240        kind: ChurnKind,
241        /// Id of the affected tool.
242        tool_id: String,
243    },
244    /// A [`crate::SkillRegistry`] search completed — the skill-side twin of
245    /// [`TraceEvent::Search`], with the same shape.
246    SkillSearch {
247        /// The search text.
248        query: String,
249        /// Direct library call vs agent-synthesized.
250        origin: Origin,
251        /// Requested result count.
252        top_k: u32,
253        /// The ranked results, best-first.
254        hits: Vec<SkillHitTrace>,
255        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
256        stages: Vec<SearchStage>,
257        /// Total search wall time, in milliseconds.
258        took_ms: u64,
259    },
260    /// The skill corpus changed — the skill-side twin of
261    /// [`TraceEvent::IndexChurn`]. [`crate::SkillRegistry::register`] emits
262    /// [`ChurnKind::Add`] only; [`crate::SkillRegistry::replace_all`] emits
263    /// either kind, and is the only source of [`ChurnKind::Remove`] for skills.
264    SkillChurn {
265        /// Whether the id was added or removed.
266        kind: ChurnKind,
267        /// Id of the affected skill.
268        skill_id: String,
269    },
270    /// A skill's body was loaded for dispatch (the `get_skill_content` path).
271    /// Emitted by the SDK skill catalogs via
272    /// [`crate::SkillRegistry::record_event`].
273    SkillInvoke {
274        /// Id of the loaded skill.
275        skill_id: String,
276        /// Load wall time, in milliseconds.
277        took_ms: u64,
278    },
279    /// A [`crate::FactRegistry`] search completed — the fact-side twin of
280    /// [`TraceEvent::SkillSearch`], with the same shape.
281    FactSearch {
282        /// The search text.
283        query: String,
284        /// Direct library call vs agent-synthesized.
285        origin: Origin,
286        /// Requested result count.
287        top_k: u32,
288        /// The ranked results, best-first.
289        hits: Vec<FactHitTrace>,
290        /// Per-engine stage timings (`bm25` / `dense` / `rrf`).
291        stages: Vec<SearchStage>,
292        /// Total search wall time, in milliseconds.
293        took_ms: u64,
294    },
295    /// The fact corpus changed — the fact-side twin of
296    /// [`TraceEvent::SkillChurn`], emitted by [`crate::FactRegistry::register`].
297    FactChurn {
298        /// Whether the id was added or removed.
299        kind: ChurnKind,
300        /// Id of the affected fact.
301        fact_id: String,
302    },
303    /// A fact's body was injected into the context by the grounding layer.
304    /// Emitted by the SDK via [`crate::FactRegistry::record_event`]; `reason`
305    /// records why the re-injection freshness gate let it through.
306    FactInject {
307        /// Id of the injected fact.
308        fact_id: String,
309        /// Why it was (re-)injected this turn.
310        reason: FactInjectReason,
311    },
312    /// A fact was *not* re-injected because it is still fresh in the context —
313    /// the token-saving half of the freshness gate, surfaced so the saving is
314    /// observable. Emitted by the SDK via
315    /// [`crate::FactRegistry::record_event`].
316    FactInjectSkip {
317        /// Id of the fact that was already present and left alone.
318        fact_id: String,
319    },
320    /// A fact rode along in a per-call grounding snapshot — the stateless
321    /// `groundSnapshot` path: recomputed each call, nothing persisted, no
322    /// freshness gate. The per-call twin of [`TraceEvent::FactInject`], emitted
323    /// by the SDK via [`crate::FactRegistry::record_event`] once per fact per
324    /// snapshot.
325    FactSnapshot {
326        /// Id of the fact included in the snapshot.
327        fact_id: String,
328    },
329    /// A tool invocation began. Emitted by the SDK catalogs just before the
330    /// tool's executor runs; paired with [`TraceEvent::InvokeEnd`] or
331    /// [`TraceEvent::InvokeError`].
332    InvokeStart {
333        /// Id of the invoked tool.
334        tool_id: String,
335        /// Size of the serialized argument payload, in bytes.
336        args_size_bytes: u64,
337    },
338    /// A tool invocation completed successfully.
339    InvokeEnd {
340        /// Id of the invoked tool.
341        tool_id: String,
342        /// Invocation wall time, in milliseconds.
343        took_ms: u64,
344    },
345    /// A tool invocation failed; `error` carries the executor's message.
346    InvokeError {
347        /// Id of the invoked tool.
348        tool_id: String,
349        /// Wall time until the failure, in milliseconds.
350        took_ms: u64,
351        /// The failure message.
352        error: String,
353    },
354    /// The agent searched the catalog through the capability tools
355    /// (`search_capabilities`, or the deprecated `search_tools`). Carries only
356    /// the hit *count*; the ranked list with scores is on the underlying
357    /// [`TraceEvent::Search`] / [`TraceEvent::SkillSearch`] the registries
358    /// emit for the same call. The `gateway_*` wire prefix is frozen
359    /// (ADR-0007: renames are breaking).
360    GatewaySearch {
361        /// The search text.
362        query: String,
363        /// Direct library call vs agent-synthesized.
364        origin: Origin,
365        /// Requested result count.
366        top_k: u32,
367        /// Number of results returned.
368        hits: u32,
369        /// Total search wall time, in milliseconds.
370        took_ms: u64,
371    },
372    /// The agent invoked a tool through the `invoke_tool` capability tool and
373    /// it succeeded.
374    GatewayInvoke {
375        /// Id of the invoked tool.
376        tool_id: String,
377        /// Invocation wall time, in milliseconds.
378        took_ms: u64,
379    },
380    /// A capability-tool call failed: an unknown tool/skill id, an executor
381    /// error, or an upstream that needs auth.
382    GatewayError {
383        /// Id of the tool (or skill) the call named.
384        tool_id: String,
385        /// The failure message (e.g. `needs_auth`).
386        error: String,
387    },
388    /// An upstream MCP server's tools were ingested into the catalog
389    /// (the SDK's `register_mcp_server`).
390    UpstreamRegister {
391        /// Upstream server name.
392        server: String,
393        /// Transport used to reach it (e.g. `stdio` / `http` / `sse`).
394        transport: String,
395        /// Number of tools ingested.
396        tool_count: u32,
397    },
398    /// A proxied call to a tool backed by an upstream MCP server completed.
399    UpstreamInvoke {
400        /// Upstream server name.
401        server: String,
402        /// Id of the invoked tool.
403        tool_id: String,
404        /// Invocation wall time, in milliseconds.
405        took_ms: u64,
406    },
407    /// A proxied upstream call failed; `error` carries the upstream's message.
408    UpstreamError {
409        /// Upstream server name.
410        server: String,
411        /// Id of the invoked tool.
412        tool_id: String,
413        /// The failure message.
414        error: String,
415    },
416    /// A credential refresh for an upstream MCP server was attempted.
417    AuthRefresh {
418        /// Upstream server name.
419        upstream: String,
420        /// Whether the refresh produced valid credentials.
421        ok: bool,
422    },
423    /// An upstream MCP server challenged for auth (e.g. a 401): user
424    /// interaction is required before its tools work.
425    AuthNeeds {
426        /// Upstream server name.
427        upstream: String,
428    },
429    /// An interactive auth flow (e.g. OAuth) started for an upstream MCP
430    /// server; paired with [`TraceEvent::AuthFlowEnd`].
431    AuthFlowStart {
432        /// Upstream server name.
433        upstream: String,
434    },
435    /// The interactive auth flow ended.
436    AuthFlowEnd {
437        /// Upstream server name.
438        upstream: String,
439        /// Whether the flow produced valid credentials.
440        ok: bool,
441    },
442    /// One fan-out subscriber lost events because its bounded queue overflowed.
443    EventsDropped {
444        /// Number of events dropped during this observation window.
445        dropped_count: u64,
446        /// Stable machine-readable loss reason; currently `queue_overflow`.
447        reason: String,
448        /// Timestamp of the first drop in this report, in Unix milliseconds.
449        window_start_ts: u64,
450        /// Timestamp of the last drop in this report, in Unix milliseconds.
451        window_end_ts: u64,
452    },
453    /// Emitted once, on the first (cold) load of the embedding model. `status`
454    /// flags a slow load (possibly underpowered machine) or a failed one;
455    /// `reason` carries the hint / error. See `embedding.rs` and ADR-0011.
456    EmbedderLoad {
457        /// Resolved model display name: repo id, local path, or endpoint model
458        /// and URL.
459        model: String,
460        /// Load outcome: ok, slow, or failed.
461        status: EmbedderLoadStatus,
462        /// Load wall time, in milliseconds (`0` when the load failed before
463        /// timing).
464        took_ms: u64,
465        /// The slow-load hint or the load error; `None` on a normal load.
466        reason: Option<String>,
467    },
468    /// Emitted once when a configured embedding model is actually downloaded to
469    /// the HuggingFace cache (a cold fetch), carrying the real byte size — so a
470    /// multi-second first-run download is never a silent surprise. See ADR-0012.
471    EmbedderDownload {
472        /// The model that was downloaded.
473        model: String,
474        /// Real download size, in bytes.
475        bytes: u64,
476    },
477    /// Emitted when a semantic/hybrid search runs against an embedding set built
478    /// with a *different* model than the one now configured. Retrieval fails
479    /// rather than mixing vector spaces; the caller must rebuild the complete
480    /// embedding cache. See `dense_cache.rs` and ADR-0012.
481    EmbedderModelMismatch {
482        /// The model the existing embeddings were built with.
483        built: String,
484        /// The model now configured.
485        active: String,
486    },
487    /// Emitted once when a semantic/hybrid search finds the attached intent
488    /// graph's centroids were built with a *different* embedding model than the
489    /// active one, so cosine across the two spaces would be meaningless. Unlike
490    /// [`Self::EmbedderModelMismatch`] (corpus, fatal), the usage arm merely
491    /// **pauses** — base ranking is unaffected — until the graph is rebuilt. See
492    /// `usage.rs` and ADR-0014.
493    UsageModelMismatch {
494        /// The graph's model — its fingerprint, or its centroid width when the
495        /// mismatch is dimensional.
496        built: String,
497        /// The active model, in the same units as `built`.
498        active: String,
499        /// `true` when the models differ in output dimension, `false` when only
500        /// the model identity differs at the same width (a same-dim swap a length
501        /// check cannot catch).
502        dim_mismatch: bool,
503    },
504    /// Emitted on every search of a registry that has an intent graph attached,
505    /// recording whether usage history contributed to the ranking (ADR-0014).
506    /// A registry with no graph emits nothing, so this event's presence is
507    /// itself the signal that adaptive ranking is switched on.
508    ///
509    /// `intent: None` is the **miss** case: the query matched no cluster and
510    /// ranked exactly as it would have with no graph at all. A rising share of
511    /// misses means the graph no longer covers what is being asked — the cue to
512    /// re-derive it.
513    ///
514    /// `intent: Some(_)` with `promoted: 0` and `dropped > 0` is a different
515    /// failure wearing similar clothes: the cluster matched, but every
516    /// capability it remembers has left the catalog, so it contributed nothing.
517    /// That is catalog drift, not a coverage gap, and re-deriving the graph
518    /// fixes it. Reading the two apart is what [`Self::UsageBoost::dropped`] is
519    /// for.
520    UsageBoost {
521        /// Id of the matched cluster; `None` when nothing cleared the match
522        /// threshold.
523        intent: Option<String>,
524        /// How well the query matched the cluster — cosine on the dense tier,
525        /// token-overlap share on the lexical one. `0.0` on a miss. Scales
526        /// differ between tiers, so compare within one. Reported so near-misses
527        /// are visible and the threshold can be judged against real traffic.
528        similarity: f64,
529        /// The matched cluster's observation count, which scales the arm's
530        /// weight. `0` on a miss.
531        support: u32,
532        /// How many capability ids the arm contributed to the fusion. `0` on a
533        /// miss.
534        promoted: u32,
535        /// How many ids the matched cluster remembers that the registry no
536        /// longer defines, so they were dropped from the arm rather than
537        /// ranked. `0` on a miss — nothing matched, so nothing was dropped.
538        ///
539        /// Non-zero means the graph and the catalog have drifted apart. It says
540        /// nothing about ranking quality on its own: an id the agent cannot
541        /// invoke must never be returned, so dropping is correct. What it
542        /// buys is that the drop is *visible* instead of being folded into the
543        /// miss rate.
544        ///
545        /// `#[serde(default)]` so a log written before this field existed still
546        /// replays: an older `usage_boost` line reads as `0`, which is what it
547        /// meant.
548        #[serde(default)]
549        dropped: u32,
550    },
551    /// Emitted once when an in-process model's pooling could not be detected
552    /// (no `1_Pooling/config.json`) and no override was given, so a mode was
553    /// assumed. A non-silent guess: set `pooling` to correct it. See ADR-0012.
554    EmbedderPoolingAssumed {
555        /// The model whose pooling could not be detected.
556        model: String,
557        /// The pooling mode that was assumed (`"cls"` or `"mean"`).
558        pooling: String,
559    },
560}
561
562impl TraceEvent {
563    pub(crate) fn catalog_definition_for_tool(tool: &Tool) -> Option<Self> {
564        Self::catalog_definition(
565            CatalogKind::Tool,
566            &tool.id,
567            &tool.name,
568            &tool.description,
569            &[],
570            Some(tool.input_schema.clone()),
571            Some(tool.output_schema.clone()),
572            tool.experimental_searchable_description.as_deref(),
573        )
574    }
575
576    pub(crate) fn catalog_definition_for_skill(skill: &Skill) -> Option<Self> {
577        Self::catalog_definition(
578            CatalogKind::Skill,
579            &skill.id,
580            &skill.name,
581            &skill.description,
582            &skill.tags,
583            None,
584            None,
585            skill.experimental_searchable_description.as_deref(),
586        )
587    }
588
589    pub(crate) fn catalog_definition_for_fact(fact: &Fact) -> Option<Self> {
590        Self::catalog_definition(
591            CatalogKind::Fact,
592            &fact.id,
593            &fact.name,
594            &fact.description,
595            &fact.tags,
596            None,
597            None,
598            fact.experimental_searchable_description.as_deref(),
599        )
600    }
601
602    pub(crate) fn catalog_definition_hash(&self) -> Option<&str> {
603        match self {
604            Self::CatalogDefinition { content_hash, .. } => Some(content_hash),
605            _ => None,
606        }
607    }
608
609    #[allow(clippy::too_many_arguments)]
610    fn catalog_definition(
611        kind: CatalogKind,
612        id: &str,
613        name: &str,
614        description: &str,
615        tags: &[String],
616        input_schema: Option<serde_json::Value>,
617        output_schema: Option<serde_json::Value>,
618        override_description: Option<&str>,
619    ) -> Option<Self> {
620        if input_schema.as_ref().is_some_and(has_unsafe_integer)
621            || output_schema.as_ref().is_some_and(has_unsafe_integer)
622        {
623            return None;
624        }
625        let searchable_description = override_description.unwrap_or(description);
626        let searchable_description_overridden = override_description.is_some();
627        let content = CatalogDefinitionContent {
628            kind,
629            id,
630            name,
631            description,
632            tags,
633            input_schema: input_schema.as_ref(),
634            output_schema: output_schema.as_ref(),
635            searchable_description,
636            searchable_description_overridden,
637        };
638        let content_hash = catalog_definition_hash(&content)?;
639        Some(Self::CatalogDefinition {
640            kind,
641            id: id.into(),
642            name: name.into(),
643            description: description.into(),
644            tags: tags.to_vec(),
645            input_schema: input_schema.map(Box::new),
646            output_schema: output_schema.map(Box::new),
647            searchable_description: searchable_description.into(),
648            searchable_description_overridden,
649            content_hash,
650        })
651    }
652}
653
654fn catalog_definition_hash(content: &CatalogDefinitionContent<'_>) -> Option<String> {
655    let canonical = serde_json_canonicalizer::to_vec(content).ok()?;
656    Some(format!("{:x}", Sha256::digest(canonical)))
657}
658
659fn has_unsafe_integer(value: &serde_json::Value) -> bool {
660    match value {
661        serde_json::Value::Number(number) => number
662            .as_f64()
663            .is_some_and(|number| number.fract() == 0.0 && number.abs() > MAX_SAFE_INTEGER),
664        serde_json::Value::Array(values) => values.iter().any(has_unsafe_integer),
665        serde_json::Value::Object(values) => values.values().any(has_unsafe_integer),
666        _ => false,
667    }
668}
669
670/// Per-event correlation fields supplied by the emitting integration.
671///
672/// Sinks fill stable envelope fields such as `event_id`, `session_id`, and
673/// `source_id`; callers use this context only for facts known at the emission
674/// site. Missing fields are omitted from the flattened JSON envelope.
675#[derive(Debug, Clone, Default, PartialEq, Eq)]
676pub struct TraceEventContext {
677    /// Client-generated id for this event. Sinks mint one when absent.
678    pub event_id: Option<String>,
679    /// Id shared by every event in one invocation lifecycle.
680    pub invocation_id: Option<String>,
681    /// Catalog revision known when the event was emitted.
682    pub catalog_version: Option<String>,
683    /// Deployment environment supplied by the application.
684    pub environment: Option<String>,
685    /// Application-provided subject id.
686    pub end_user_id: Option<String>,
687    /// Active OpenTelemetry trace id, when available.
688    pub trace_id: Option<String>,
689    /// Active OpenTelemetry span id, when available.
690    pub span_id: Option<String>,
691}
692
693impl TraceEventContext {
694    /// Create context for one invocation lifecycle with a fresh opaque id.
695    /// Clone and pass it with the start and terminal event so concurrent calls
696    /// to the same tool remain paired even when they finish out of order.
697    pub fn new_invocation() -> Self {
698        Self {
699            invocation_id: Some(ulid::Ulid::new().to_string()),
700            ..Self::default()
701        }
702    }
703}
704
705/// The versioned wrapper a sink writes around each [`TraceEvent`]: schema
706/// version, stable identity, timestamp, and correlation fields. On the wire the event is flattened
707/// (`#[serde(flatten)]`), so its `type` tag and fields sit beside `v` / `ts` /
708/// `session_id` in one JSON object.
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
710pub struct TraceEnvelope {
711    /// Envelope schema version; currently `2`.
712    pub v: u32,
713    /// Client-generated ULID identifying exactly this event.
714    #[serde(default)]
715    pub event_id: String,
716    /// Event time, in milliseconds since the Unix epoch.
717    pub ts: u64,
718    /// The session the event belongs to, as given to the sink — correlates
719    /// all events from one agent session.
720    pub session_id: String,
721    /// Stable source identity shared by events and catalog snapshots.
722    #[serde(default)]
723    pub source_id: String,
724    /// Id shared by every event in one invocation lifecycle.
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub invocation_id: Option<String>,
727    /// Catalog revision known when the event was emitted.
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub catalog_version: Option<String>,
730    /// Deployment environment supplied by the application.
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub environment: Option<String>,
733    /// Application-provided subject id.
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub end_user_id: Option<String>,
736    /// Active OpenTelemetry trace id, when available.
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub trace_id: Option<String>,
739    /// Active OpenTelemetry span id, when available.
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub span_id: Option<String>,
742    /// The event itself, flattened into the envelope on the wire.
743    #[serde(flatten)]
744    pub event: TraceEvent,
745}