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/// Classify compatibility between a reader (current) and writer version without
237/// emitting tracing spans or metrics.
238///
239/// The reader version is always the current version for the given schema kind.
240/// The writer version is the version found in the data being read.
241pub fn classify_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
242    let reader_version = kind.current_version();
243
244    let compatibility = if writer_version == reader_version {
245        Compatibility::Exact
246    } else {
247        match (
248            parse_version_number(kind, reader_version),
249            parse_version_number(kind, writer_version),
250        ) {
251            (Some(rv), Some(wv)) if rv > wv => Compatibility::Forward {
252                reader_version: rv,
253                writer_version: wv,
254            },
255            (Some(rv), Some(wv)) if rv == wv => Compatibility::Exact,
256            (Some(rv), Some(wv)) => Compatibility::Backward {
257                reader_version: rv,
258                writer_version: wv,
259            },
260            _ => Compatibility::Unknown {
261                writer_version: writer_version.to_string(),
262            },
263        }
264    };
265
266    CompatCheckResult {
267        kind,
268        reader_version,
269        writer_version: writer_version.to_string(),
270        compatibility,
271    }
272}
273
274/// Check compatibility between a reader (current) and writer version.
275///
276/// The reader version is always the current version for the given schema kind.
277/// The writer version is the version found in the data being read.
278///
279/// Emits a `trace.compat_check` tracing span and increments
280/// `trace_compat_failures_total` on incompatibility.
281pub fn check_schema_compat(kind: SchemaKind, writer_version: &str) -> CompatCheckResult {
282    let result = classify_schema_compat(kind, writer_version);
283    let compatible = result.is_compatible();
284
285    // Tracing span
286    #[cfg(feature = "tracing")]
287    {
288        use tracing::{error, info_span};
289
290        let span = info_span!(
291            "trace.compat_check",
292            schema_version = kind.current_version(),
293            reader_version = result.reader_version,
294            writer_version = writer_version,
295            compatible = compatible,
296        );
297        let _guard = span.enter();
298
299        if !compatible {
300            error!(
301                schema_kind = kind.as_str(),
302                reader_version = result.reader_version,
303                writer_version = writer_version,
304                "trace schema version incompatible"
305            );
306        }
307    }
308
309    // Metrics
310    if !compatible {
311        METRICS
312            .counter(BuiltinCounter::TraceCompatFailuresTotal)
313            .inc();
314    }
315
316    result
317}
318
319/// Convenience: check evidence schema compatibility.
320pub fn check_evidence_compat(writer_version: &str) -> CompatCheckResult {
321    check_schema_compat(SchemaKind::Evidence, writer_version)
322}
323
324/// Convenience: check render-trace schema compatibility.
325pub fn check_render_trace_compat(writer_version: &str) -> CompatCheckResult {
326    check_schema_compat(SchemaKind::RenderTrace, writer_version)
327}
328
329/// Convenience: check event-trace schema compatibility.
330pub fn check_event_trace_compat(writer_version: &str) -> CompatCheckResult {
331    check_schema_compat(SchemaKind::EventTrace, writer_version)
332}
333
334/// Convenience: check golden-trace schema compatibility.
335pub fn check_golden_trace_compat(writer_version: &str) -> CompatCheckResult {
336    check_schema_compat(SchemaKind::GoldenTrace, writer_version)
337}
338
339// ============================================================================
340// Compatibility Matrix
341// ============================================================================
342
343/// Entry in the compatibility matrix, pairing a schema kind with a
344/// writer version and expected outcome.
345#[derive(Debug, Clone)]
346pub struct MatrixEntry {
347    pub kind: SchemaKind,
348    pub writer_version: String,
349    pub expected_compatible: bool,
350}
351
352/// Run the full compatibility matrix and return all results.
353///
354/// This is the CI gate function: every entry must match its expected outcome.
355pub fn run_compatibility_matrix(entries: &[MatrixEntry]) -> Vec<(MatrixEntry, CompatCheckResult)> {
356    entries
357        .iter()
358        .map(|entry| {
359            let result = classify_schema_compat(entry.kind, &entry.writer_version);
360            (entry.clone(), result)
361        })
362        .collect()
363}
364
365/// Build the default compatibility matrix covering all schema kinds.
366///
367/// For each kind, tests:
368/// - Current version (exact match, compatible)
369/// - One version older (forward compatible)
370/// - One version newer (backward incompatible)
371/// - Garbage version string (unknown, incompatible)
372pub fn default_compatibility_matrix() -> Vec<MatrixEntry> {
373    let mut entries = Vec::new();
374
375    for kind in SchemaKind::ALL {
376        let current = kind.current_version();
377
378        // Exact match — always compatible
379        entries.push(MatrixEntry {
380            kind,
381            writer_version: current.to_string(),
382            expected_compatible: true,
383        });
384
385        // Garbage — always incompatible
386        entries.push(MatrixEntry {
387            kind,
388            writer_version: "not-a-version".to_string(),
389            expected_compatible: false,
390        });
391
392        if kind == SchemaKind::Telemetry {
393            // Semver: older major version is forward-compatible
394            entries.push(MatrixEntry {
395                kind,
396                writer_version: "0.9.0".to_string(),
397                expected_compatible: true,
398            });
399            // Semver: newer major version is backward-incompatible
400            entries.push(MatrixEntry {
401                kind,
402                writer_version: "2.0.0".to_string(),
403                expected_compatible: false,
404            });
405        } else {
406            // Prefixed: generate older and newer versions
407            let prefix = kind.version_prefix();
408            if let Some(current_num) = parse_version_number(kind, current) {
409                if current_num > 0 {
410                    entries.push(MatrixEntry {
411                        kind,
412                        writer_version: format!("{prefix}{}", current_num - 1),
413                        expected_compatible: true,
414                    });
415                }
416                entries.push(MatrixEntry {
417                    kind,
418                    writer_version: format!("{prefix}{}", current_num + 1),
419                    expected_compatible: false,
420                });
421            }
422        }
423    }
424
425    entries
426}
427
428// ============================================================================
429// Tests
430// ============================================================================
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    static COMPAT_METRICS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
437
438    fn compat_metrics_guard() -> std::sync::MutexGuard<'static, ()> {
439        COMPAT_METRICS_LOCK
440            .lock()
441            .unwrap_or_else(std::sync::PoisonError::into_inner)
442    }
443
444    #[test]
445    fn exact_match_all_kinds() {
446        for kind in SchemaKind::ALL {
447            let result = classify_schema_compat(kind, kind.current_version());
448            assert_eq!(result.compatibility, Compatibility::Exact, "{kind}");
449            assert!(result.is_compatible(), "{kind}");
450        }
451    }
452
453    #[test]
454    fn forward_compat_evidence() {
455        let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
456        assert!(
457            matches!(
458                result.compatibility,
459                Compatibility::Forward {
460                    reader_version: 2,
461                    writer_version: 1
462                }
463            ),
464            "got {:?}",
465            result.compatibility
466        );
467        assert!(result.is_compatible());
468    }
469
470    #[test]
471    fn backward_incompat_evidence() {
472        let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v3");
473        assert!(
474            matches!(
475                result.compatibility,
476                Compatibility::Backward {
477                    reader_version: 2,
478                    writer_version: 3
479                }
480            ),
481            "got {:?}",
482            result.compatibility
483        );
484        assert!(!result.is_compatible());
485    }
486
487    #[test]
488    fn unknown_version_format() {
489        let result = classify_schema_compat(SchemaKind::Evidence, "garbage-string");
490        assert!(
491            matches!(result.compatibility, Compatibility::Unknown { .. }),
492            "got {:?}",
493            result.compatibility
494        );
495        assert!(!result.is_compatible());
496    }
497
498    #[test]
499    fn forward_compat_telemetry_semver() {
500        let result = classify_schema_compat(SchemaKind::Telemetry, "0.9.0");
501        assert!(
502            matches!(
503                result.compatibility,
504                Compatibility::Forward {
505                    reader_version: 1,
506                    writer_version: 0
507                }
508            ),
509            "got {:?}",
510            result.compatibility
511        );
512        assert!(result.is_compatible());
513    }
514
515    #[test]
516    fn backward_incompat_telemetry_semver() {
517        let result = classify_schema_compat(SchemaKind::Telemetry, "2.0.0");
518        assert!(
519            matches!(
520                result.compatibility,
521                Compatibility::Backward {
522                    reader_version: 1,
523                    writer_version: 2
524                }
525            ),
526            "got {:?}",
527            result.compatibility
528        );
529        assert!(!result.is_compatible());
530    }
531
532    #[test]
533    fn all_kinds_have_current_version() {
534        for kind in SchemaKind::ALL {
535            let v = kind.current_version();
536            assert!(!v.is_empty(), "{kind} has empty version");
537        }
538    }
539
540    #[test]
541    fn all_kinds_have_unique_versions() {
542        let mut versions = std::collections::HashSet::new();
543        for kind in SchemaKind::ALL {
544            assert!(
545                versions.insert(kind.current_version()),
546                "duplicate version: {}",
547                kind.current_version()
548            );
549        }
550    }
551
552    #[test]
553    fn default_matrix_covers_all_kinds() {
554        let matrix = default_compatibility_matrix();
555        for kind in SchemaKind::ALL {
556            let count = matrix.iter().filter(|e| e.kind == kind).count();
557            assert!(
558                count >= 3,
559                "{kind} has only {count} matrix entries, expected >=3"
560            );
561        }
562    }
563
564    #[test]
565    fn default_matrix_all_pass() {
566        let matrix = default_compatibility_matrix();
567        let results = run_compatibility_matrix(&matrix);
568        for (entry, result) in &results {
569            assert_eq!(
570                result.is_compatible(),
571                entry.expected_compatible,
572                "{}: writer={}, expected_compatible={}, got {:?}",
573                entry.kind,
574                entry.writer_version,
575                entry.expected_compatible,
576                result.compatibility,
577            );
578        }
579    }
580
581    #[test]
582    fn compat_failures_counter_increments() {
583        let _guard = compat_metrics_guard();
584
585        let before = METRICS
586            .counter(BuiltinCounter::TraceCompatFailuresTotal)
587            .get();
588        let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v99");
589        let after = METRICS
590            .counter(BuiltinCounter::TraceCompatFailuresTotal)
591            .get();
592        assert!(
593            after > before,
594            "counter should increment on incompatibility"
595        );
596    }
597
598    #[test]
599    fn exact_match_does_not_increment_counter() {
600        let _guard = compat_metrics_guard();
601
602        let before = METRICS
603            .counter(BuiltinCounter::TraceCompatFailuresTotal)
604            .get();
605        let _ = check_schema_compat(SchemaKind::Evidence, "ftui-evidence-v2");
606        let after = METRICS
607            .counter(BuiltinCounter::TraceCompatFailuresTotal)
608            .get();
609        assert_eq!(after, before, "counter should not increment on exact match");
610    }
611
612    #[test]
613    fn display_impls() {
614        let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v1");
615        let s = result.to_string();
616        assert!(s.contains("evidence"), "{s}");
617        assert!(s.contains("forward compatible"), "{s}");
618
619        let result2 = classify_schema_compat(SchemaKind::RenderTrace, "render-trace-v99");
620        let s2 = result2.to_string();
621        assert!(s2.contains("incompatible"), "{s2}");
622    }
623
624    #[test]
625    fn schema_kind_display() {
626        assert_eq!(SchemaKind::Evidence.to_string(), "evidence");
627        assert_eq!(SchemaKind::RenderTrace.to_string(), "render_trace");
628        assert_eq!(SchemaKind::Telemetry.to_string(), "telemetry");
629    }
630
631    #[test]
632    fn render_trace_forward_compat() {
633        let result = classify_schema_compat(SchemaKind::RenderTrace, "render-trace-v0");
634        assert!(result.is_compatible());
635        assert!(matches!(
636            result.compatibility,
637            Compatibility::Forward { .. }
638        ));
639    }
640
641    #[test]
642    fn event_trace_exact() {
643        let result = classify_schema_compat(SchemaKind::EventTrace, "event-trace-v1");
644        assert_eq!(result.compatibility, Compatibility::Exact);
645    }
646
647    #[test]
648    fn golden_trace_backward_incompat() {
649        let result = classify_schema_compat(SchemaKind::GoldenTrace, "golden-trace-v2");
650        assert!(!result.is_compatible());
651    }
652
653    #[test]
654    fn migration_ir_exact() {
655        let result = classify_schema_compat(SchemaKind::MigrationIr, "migration-ir-v1");
656        assert_eq!(result.compatibility, Compatibility::Exact);
657    }
658
659    #[test]
660    fn evidence_v0_forward() {
661        // Evidence reader is v2, writer is v0 → forward compatible
662        let result = classify_schema_compat(SchemaKind::Evidence, "ftui-evidence-v0");
663        assert!(result.is_compatible());
664        assert!(matches!(
665            result.compatibility,
666            Compatibility::Forward {
667                reader_version: 2,
668                writer_version: 0
669            }
670        ));
671    }
672}