Skip to main content

ftui_runtime/
schema_compat.rs

1#![forbid(unsafe_code)]
2
3//! Suite-wide trace and evidence schema compatibility (bd-ehk.3).
4//!
5//! Centralizes schema version constants for all FrankenTUI trace and evidence
6//! formats, and provides a compatibility checker that validates reader/writer
7//! version pairs.
8//!
9//! # Schema Kinds
10//!
11//! | Kind           | Current Version        | Format   |
12//! |----------------|------------------------|----------|
13//! | Evidence       | `ftui-evidence-v2`     | JSONL    |
14//! | RenderTrace    | `render-trace-v1`      | JSONL    |
15//! | EventTrace     | `event-trace-v1`       | JSONL.gz |
16//! | GoldenTrace    | `golden-trace-v1`      | JSONL    |
17//! | Telemetry      | `1.0.0`                | OTLP     |
18//! | MigrationIr    | `migration-ir-v1`      | JSON     |
19//!
20//! # Compatibility Rules
21//!
22//! - **Exact**: reader version == writer version → always compatible.
23//! - **Forward**: reader is newer than writer → compatible (reader can
24//!   understand older formats).
25//! - **Backward**: writer is newer than reader → incompatible (reader cannot
26//!   understand newer formats without migration).
27//! - **Unknown**: version string doesn't match the expected prefix for its
28//!   schema kind → incompatible.
29//!
30//! # Tracing
31//!
32//! Every compatibility check emits a `trace.compat_check` span with fields:
33//! `schema_version`, `reader_version`, `writer_version`, `compatible`.
34//! Incompatible checks log at ERROR level.
35//!
36//! # Metrics
37//!
38//! Incompatible checks increment `trace_compat_failures_total` via
39//! [`BuiltinCounter::TraceCompatFailuresTotal`].
40
41use std::fmt;
42
43use crate::metrics_registry::{BuiltinCounter, METRICS};
44
45// ============================================================================
46// Schema Kind
47// ============================================================================
48
49/// All schema kinds in the FrankenTUI suite.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum SchemaKind {
52    /// Unified evidence ledger JSONL (`ftui-evidence-v{N}`).
53    Evidence,
54    /// Render-trace JSONL (`render-trace-v{N}`).
55    RenderTrace,
56    /// Event-trace JSONL.gz (`event-trace-v{N}`).
57    EventTrace,
58    /// Golden-trace JSONL (`golden-trace-v{N}`).
59    GoldenTrace,
60    /// Telemetry OTLP (`{major}.{minor}.{patch}`).
61    Telemetry,
62    /// Doctor migration IR JSON (`migration-ir-v{N}`).
63    MigrationIr,
64}
65
66impl SchemaKind {
67    /// All schema kinds.
68    pub const ALL: [Self; 6] = [
69        Self::Evidence,
70        Self::RenderTrace,
71        Self::EventTrace,
72        Self::GoldenTrace,
73        Self::Telemetry,
74        Self::MigrationIr,
75    ];
76
77    /// Current version string for this schema kind.
78    pub const fn current_version(self) -> &'static str {
79        match self {
80            Self::Evidence => "ftui-evidence-v2",
81            Self::RenderTrace => "render-trace-v1",
82            Self::EventTrace => "event-trace-v1",
83            Self::GoldenTrace => "golden-trace-v1",
84            Self::Telemetry => "1.0.0",
85            Self::MigrationIr => "migration-ir-v1",
86        }
87    }
88
89    /// Version prefix (everything before the version number).
90    const fn version_prefix(self) -> &'static str {
91        match self {
92            Self::Evidence => "ftui-evidence-v",
93            Self::RenderTrace => "render-trace-v",
94            Self::EventTrace => "event-trace-v",
95            Self::GoldenTrace => "golden-trace-v",
96            Self::Telemetry => "", // semver, handled separately
97            Self::MigrationIr => "migration-ir-v",
98        }
99    }
100
101    /// Human-readable name for display.
102    pub const fn as_str(self) -> &'static str {
103        match self {
104            Self::Evidence => "evidence",
105            Self::RenderTrace => "render_trace",
106            Self::EventTrace => "event_trace",
107            Self::GoldenTrace => "golden_trace",
108            Self::Telemetry => "telemetry",
109            Self::MigrationIr => "migration_ir",
110        }
111    }
112}
113
114impl fmt::Display for SchemaKind {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(self.as_str())
117    }
118}
119
120// ============================================================================
121// Compatibility Result
122// ============================================================================
123
124/// Outcome of a schema compatibility check.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Compatibility {
127    /// Versions match exactly.
128    Exact,
129    /// Reader is newer than writer — forward compatible (reader can read older data).
130    Forward {
131        reader_version: u32,
132        writer_version: u32,
133    },
134    /// Writer is newer than reader — incompatible (needs migration).
135    Backward {
136        reader_version: u32,
137        writer_version: u32,
138    },
139    /// Version string doesn't match expected format for this schema kind.
140    Unknown { writer_version: String },
141}
142
143impl Compatibility {
144    /// Whether the reader can process data from the writer.
145    pub fn is_compatible(&self) -> bool {
146        matches!(self, Self::Exact | Self::Forward { .. })
147    }
148}
149
150impl fmt::Display for Compatibility {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            Self::Exact => write!(f, "exact match"),
154            Self::Forward {
155                reader_version,
156                writer_version,
157            } => write!(
158                f,
159                "forward compatible (reader=v{reader_version}, writer=v{writer_version})"
160            ),
161            Self::Backward {
162                reader_version,
163                writer_version,
164            } => write!(
165                f,
166                "incompatible: writer newer (reader=v{reader_version}, writer=v{writer_version})"
167            ),
168            Self::Unknown { writer_version } => {
169                write!(f, "unknown version format: {writer_version}")
170            }
171        }
172    }
173}
174
175// ============================================================================
176// Compatibility Check Result
177// ============================================================================
178
179/// Full result of a schema compatibility check, including metadata.
180#[derive(Debug, Clone)]
181pub struct CompatCheckResult {
182    /// Schema kind that was checked.
183    pub kind: SchemaKind,
184    /// Reader's version string.
185    pub reader_version: &'static str,
186    /// Writer's version string.
187    pub writer_version: String,
188    /// Compatibility outcome.
189    pub compatibility: Compatibility,
190}
191
192impl CompatCheckResult {
193    /// Whether this check passed (reader can process writer's data).
194    pub fn is_compatible(&self) -> bool {
195        self.compatibility.is_compatible()
196    }
197}
198
199impl fmt::Display for CompatCheckResult {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(
202            f,
203            "{}: {} (reader={}, writer={})",
204            self.kind, self.compatibility, self.reader_version, self.writer_version,
205        )
206    }
207}
208
209// ============================================================================
210// Version Parsing
211// ============================================================================
212
213/// Parse a version number from a prefixed version string (e.g., "ftui-evidence-v2" → 2).
214fn parse_prefixed_version(version: &str, prefix: &str) -> Option<u32> {
215    version.strip_prefix(prefix)?.parse().ok()
216}
217
218/// Parse the major version from a semver string (e.g., "1.0.0" → 1).
219fn parse_semver_major(version: &str) -> Option<u32> {
220    version.split('.').next()?.parse().ok()
221}
222
223/// Parse the version number for a given schema kind.
224fn parse_version_number(kind: SchemaKind, version: &str) -> Option<u32> {
225    if kind == SchemaKind::Telemetry {
226        parse_semver_major(version)
227    } else {
228        parse_prefixed_version(version, kind.version_prefix())
229    }
230}
231
232// ============================================================================
233// Core Compatibility Check
234// ============================================================================
235
236/// Check compatibility between a reader (current) and writer version.
237///
238/// The reader version is always the current version for the given schema kind.
239/// The writer version is the version found in the data being read.
240///
241/// Emits a `trace.compat_check` tracing span and increments
242/// `trace_compat_failures_total` on incompatibility.
243pub fn check_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
244    let reader_version = kind.current_version();
245
246    let compatibility = if writer_version == reader_version {
247        Compatibility::Exact
248    } else {
249        match (
250            parse_version_number(kind, reader_version),
251            parse_version_number(kind, writer_version),
252        ) {
253            (Some(rv), Some(wv)) if rv > wv => Compatibility::Forward {
254                reader_version: rv,
255                writer_version: wv,
256            },
257            (Some(rv), Some(wv)) if rv == wv => Compatibility::Exact,
258            (Some(rv), Some(wv)) => Compatibility::Backward {
259                reader_version: rv,
260                writer_version: wv,
261            },
262            _ => Compatibility::Unknown {
263                writer_version: writer_version.to_string(),
264            },
265        }
266    };
267
268    let compatible = compatibility.is_compatible();
269
270    // Tracing span
271    #[cfg(feature = "tracing")]
272    {
273        use tracing::{error, info_span};
274
275        let span = info_span!(
276            "trace.compat_check",
277            schema_version = kind.current_version(),
278            reader_version = reader_version,
279            writer_version = writer_version,
280            compatible = compatible,
281        );
282        let _guard = span.enter();
283
284        if !compatible {
285            error!(
286                schema_kind = kind.as_str(),
287                reader_version = reader_version,
288                writer_version = writer_version,
289                "trace schema version incompatible"
290            );
291        }
292    }
293
294    // Metrics
295    if !compatible {
296        METRICS
297            .counter(BuiltinCounter::TraceCompatFailuresTotal)
298            .inc();
299    }
300
301    CompatCheckResult {
302        kind,
303        reader_version,
304        writer_version: writer_version.to_string(),
305        compatibility,
306    }
307}
308
309/// Convenience: check evidence schema compatibility.
310pub fn check_evidence_compat(writer_version: &str) -> CompatCheckResult {
311    check_schema_compat(SchemaKind::Evidence, writer_version)
312}
313
314/// Convenience: check render-trace schema compatibility.
315pub fn check_render_trace_compat(writer_version: &str) -> CompatCheckResult {
316    check_schema_compat(SchemaKind::RenderTrace, writer_version)
317}
318
319/// Convenience: check event-trace schema compatibility.
320pub fn check_event_trace_compat(writer_version: &str) -> CompatCheckResult {
321    check_schema_compat(SchemaKind::EventTrace, writer_version)
322}
323
324/// Convenience: check golden-trace schema compatibility.
325pub fn check_golden_trace_compat(writer_version: &str) -> CompatCheckResult {
326    check_schema_compat(SchemaKind::GoldenTrace, writer_version)
327}
328
329// ============================================================================
330// Compatibility Matrix
331// ============================================================================
332
333/// Entry in the compatibility matrix, pairing a schema kind with a
334/// writer version and expected outcome.
335#[derive(Debug, Clone)]
336pub struct MatrixEntry {
337    pub kind: SchemaKind,
338    pub writer_version: String,
339    pub expected_compatible: bool,
340}
341
342/// Run the full compatibility matrix and return all results.
343///
344/// This is the CI gate function: every entry must match its expected outcome.
345pub fn run_compatibility_matrix(entries: &[MatrixEntry]) -> Vec<(MatrixEntry, CompatCheckResult)> {
346    entries
347        .iter()
348        .map(|entry| {
349            let result = check_schema_compat(entry.kind, &entry.writer_version);
350            (entry.clone(), result)
351        })
352        .collect()
353}
354
355/// Build the default compatibility matrix covering all schema kinds.
356///
357/// For each kind, tests:
358/// - Current version (exact match, compatible)
359/// - One version older (forward compatible)
360/// - One version newer (backward incompatible)
361/// - Garbage version string (unknown, incompatible)
362pub fn default_compatibility_matrix() -> Vec<MatrixEntry> {
363    let mut entries = Vec::new();
364
365    for kind in SchemaKind::ALL {
366        let current = kind.current_version();
367
368        // Exact match — always compatible
369        entries.push(MatrixEntry {
370            kind,
371            writer_version: current.to_string(),
372            expected_compatible: true,
373        });
374
375        // Garbage — always incompatible
376        entries.push(MatrixEntry {
377            kind,
378            writer_version: "not-a-version".to_string(),
379            expected_compatible: false,
380        });
381
382        if kind == SchemaKind::Telemetry {
383            // Semver: older major version is forward-compatible
384            entries.push(MatrixEntry {
385                kind,
386                writer_version: "0.9.0".to_string(),
387                expected_compatible: true,
388            });
389            // Semver: newer major version is backward-incompatible
390            entries.push(MatrixEntry {
391                kind,
392                writer_version: "2.0.0".to_string(),
393                expected_compatible: false,
394            });
395        } else {
396            // Prefixed: generate older and newer versions
397            let prefix = kind.version_prefix();
398            if let Some(current_num) = parse_version_number(kind, current) {
399                if current_num > 0 {
400                    entries.push(MatrixEntry {
401                        kind,
402                        writer_version: format!("{prefix}{}", current_num - 1),
403                        expected_compatible: true,
404                    });
405                }
406                entries.push(MatrixEntry {
407                    kind,
408                    writer_version: format!("{prefix}{}", current_num + 1),
409                    expected_compatible: false,
410                });
411            }
412        }
413    }
414
415    entries
416}
417
418// ============================================================================
419// Tests
420// ============================================================================
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn exact_match_all_kinds() {
428        for kind in SchemaKind::ALL {
429            let result = check_schema_compat(kind, kind.current_version());
430            assert_eq!(result.compatibility, Compatibility::Exact, "{kind}");
431            assert!(result.is_compatible(), "{kind}");
432        }
433    }
434
435    #[test]
436    fn forward_compat_evidence() {
437        let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
438        assert!(
439            matches!(
440                result.compatibility,
441                Compatibility::Forward {
442                    reader_version: 2,
443                    writer_version: 1
444                }
445            ),
446            "got {:?}",
447            result.compatibility
448        );
449        assert!(result.is_compatible());
450    }
451
452    #[test]
453    fn backward_incompat_evidence() {
454        let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v3");
455        assert!(
456            matches!(
457                result.compatibility,
458                Compatibility::Backward {
459                    reader_version: 2,
460                    writer_version: 3
461                }
462            ),
463            "got {:?}",
464            result.compatibility
465        );
466        assert!(!result.is_compatible());
467    }
468
469    #[test]
470    fn unknown_version_format() {
471        let result = check_schema_compat(SchemaKind::Evidence, "garbage-string");
472        assert!(
473            matches!(result.compatibility, Compatibility::Unknown { .. }),
474            "got {:?}",
475            result.compatibility
476        );
477        assert!(!result.is_compatible());
478    }
479
480    #[test]
481    fn forward_compat_telemetry_semver() {
482        let result = check_schema_compat(SchemaKind::Telemetry, "0.9.0");
483        assert!(
484            matches!(
485                result.compatibility,
486                Compatibility::Forward {
487                    reader_version: 1,
488                    writer_version: 0
489                }
490            ),
491            "got {:?}",
492            result.compatibility
493        );
494        assert!(result.is_compatible());
495    }
496
497    #[test]
498    fn backward_incompat_telemetry_semver() {
499        let result = check_schema_compat(SchemaKind::Telemetry, "2.0.0");
500        assert!(
501            matches!(
502                result.compatibility,
503                Compatibility::Backward {
504                    reader_version: 1,
505                    writer_version: 2
506                }
507            ),
508            "got {:?}",
509            result.compatibility
510        );
511        assert!(!result.is_compatible());
512    }
513
514    #[test]
515    fn all_kinds_have_current_version() {
516        for kind in SchemaKind::ALL {
517            let v = kind.current_version();
518            assert!(!v.is_empty(), "{kind} has empty version");
519        }
520    }
521
522    #[test]
523    fn all_kinds_have_unique_versions() {
524        let mut versions = std::collections::HashSet::new();
525        for kind in SchemaKind::ALL {
526            assert!(
527                versions.insert(kind.current_version()),
528                "duplicate version: {}",
529                kind.current_version()
530            );
531        }
532    }
533
534    #[test]
535    fn default_matrix_covers_all_kinds() {
536        let matrix = default_compatibility_matrix();
537        for kind in SchemaKind::ALL {
538            let count = matrix.iter().filter(|e| e.kind == kind).count();
539            assert!(
540                count >= 3,
541                "{kind} has only {count} matrix entries, expected >=3"
542            );
543        }
544    }
545
546    #[test]
547    fn default_matrix_all_pass() {
548        let matrix = default_compatibility_matrix();
549        let results = run_compatibility_matrix(&matrix);
550        for (entry, result) in &results {
551            assert_eq!(
552                result.is_compatible(),
553                entry.expected_compatible,
554                "{}: writer={}, expected_compatible={}, got {:?}",
555                entry.kind,
556                entry.writer_version,
557                entry.expected_compatible,
558                result.compatibility,
559            );
560        }
561    }
562
563    #[test]
564    fn compat_failures_counter_increments() {
565        let before = METRICS
566            .counter(BuiltinCounter::TraceCompatFailuresTotal)
567            .get();
568        let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v99");
569        let after = METRICS
570            .counter(BuiltinCounter::TraceCompatFailuresTotal)
571            .get();
572        assert!(
573            after > before,
574            "counter should increment on incompatibility"
575        );
576    }
577
578    #[test]
579    fn exact_match_does_not_increment_counter() {
580        let before = METRICS
581            .counter(BuiltinCounter::TraceCompatFailuresTotal)
582            .get();
583        let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v2");
584        let after = METRICS
585            .counter(BuiltinCounter::TraceCompatFailuresTotal)
586            .get();
587        assert_eq!(after, before, "counter should not increment on exact match");
588    }
589
590    #[test]
591    fn display_impls() {
592        let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
593        let s = result.to_string();
594        assert!(s.contains("evidence"), "{s}");
595        assert!(s.contains("forward compatible"), "{s}");
596
597        let result2 = check_schema_compat(SchemaKind::RenderTrace, "render-trace-v99");
598        let s2 = result2.to_string();
599        assert!(s2.contains("incompatible"), "{s2}");
600    }
601
602    #[test]
603    fn schema_kind_display() {
604        assert_eq!(SchemaKind::Evidence.to_string(), "evidence");
605        assert_eq!(SchemaKind::RenderTrace.to_string(), "render_trace");
606        assert_eq!(SchemaKind::Telemetry.to_string(), "telemetry");
607    }
608
609    #[test]
610    fn render_trace_forward_compat() {
611        let result = check_schema_compat(SchemaKind::RenderTrace, "render-trace-v0");
612        assert!(result.is_compatible());
613        assert!(matches!(
614            result.compatibility,
615            Compatibility::Forward { .. }
616        ));
617    }
618
619    #[test]
620    fn event_trace_exact() {
621        let result = check_schema_compat(SchemaKind::EventTrace, "event-trace-v1");
622        assert_eq!(result.compatibility, Compatibility::Exact);
623    }
624
625    #[test]
626    fn golden_trace_backward_incompat() {
627        let result = check_schema_compat(SchemaKind::GoldenTrace, "golden-trace-v2");
628        assert!(!result.is_compatible());
629    }
630
631    #[test]
632    fn migration_ir_exact() {
633        let result = check_schema_compat(SchemaKind::MigrationIr, "migration-ir-v1");
634        assert_eq!(result.compatibility, Compatibility::Exact);
635    }
636
637    #[test]
638    fn evidence_v0_forward() {
639        // Evidence reader is v2, writer is v0 → forward compatible
640        let result = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v0");
641        assert!(result.is_compatible());
642        assert!(matches!(
643            result.compatibility,
644            Compatibility::Forward {
645                reader_version: 2,
646                writer_version: 0
647            }
648        ));
649    }
650}