Skip to main content

vox_codegen/
schema_compat.rs

1//! Service schema snapshot and compatibility tooling.
2//!
3//! Vox runtime compatibility is executed by phon decode plans. This module is the
4//! tooling layer over the same semantic primitive: snapshot a service's phon
5//! schema closures, compare two snapshots by attempting phon compatibility plans
6//! in both directions, and enforce a project policy over the reported facts.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use facet::Facet;
11use phon_engine::Registry;
12use phon_engine::plan::{CompatDirection, compatibility_direction};
13use phon_schema::{Schema, SchemaId};
14use vox_types::{BindingDirection, ServiceDescriptor};
15
16#[derive(Debug)]
17pub enum SchemaCompatError {
18    Snapshot {
19        service: &'static str,
20        method: &'static str,
21        direction: BindingDirection,
22        message: String,
23    },
24    Hex(String),
25    SchemaClosure(String),
26}
27
28impl std::fmt::Display for SchemaCompatError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Snapshot {
32                service,
33                method,
34                direction,
35                message,
36            } => write!(
37                f,
38                "failed to snapshot {service}.{method} {direction:?}: {message}"
39            ),
40            Self::Hex(message) => write!(f, "{message}"),
41            Self::SchemaClosure(message) => write!(f, "{message}"),
42        }
43    }
44}
45
46impl std::error::Error for SchemaCompatError {}
47
48#[derive(Facet, Clone, Debug)]
49pub struct SchemaCompatSnapshot {
50    pub services: Vec<ServiceSchemaSnapshot>,
51}
52
53#[derive(Facet, Clone, Debug)]
54pub struct ServiceSchemaSnapshot {
55    pub service_name: String,
56    pub methods: Vec<MethodSchemaSnapshot>,
57}
58
59#[derive(Facet, Clone, Debug)]
60pub struct MethodSchemaSnapshot {
61    pub method_name: String,
62    pub method_id: String,
63    pub args: SchemaRootSnapshot,
64    pub response: SchemaRootSnapshot,
65}
66
67#[derive(Facet, Clone, Debug)]
68pub struct SchemaRootSnapshot {
69    pub root: String,
70    pub closure: String,
71}
72
73#[derive(Facet, Clone, Debug)]
74pub struct SchemaCompatReport {
75    pub comparisons: Vec<SchemaCompatComparison>,
76}
77
78#[derive(Facet, Clone, Debug)]
79pub struct SchemaCompatComparison {
80    pub service_name: String,
81    pub method_name: String,
82    pub direction: SchemaCompatComparisonDirection,
83    pub status: SchemaCompatStatus,
84    pub note: Option<String>,
85}
86
87#[derive(Facet, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
88#[repr(u8)]
89pub enum SchemaCompatComparisonDirection {
90    Args,
91    Response,
92    Method,
93}
94
95#[derive(Facet, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
96#[repr(u8)]
97pub enum SchemaCompatStatus {
98    Backward,
99    Forward,
100    Bidirectional,
101    Incompatible,
102}
103
104impl From<CompatDirection> for SchemaCompatStatus {
105    fn from(value: CompatDirection) -> Self {
106        match value {
107            CompatDirection::Backward => Self::Backward,
108            CompatDirection::Forward => Self::Forward,
109            CompatDirection::Bidirectional => Self::Bidirectional,
110            CompatDirection::Incompatible => Self::Incompatible,
111        }
112    }
113}
114
115#[derive(Facet, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
116pub struct SchemaCompatAcknowledgement {
117    pub service_name: String,
118    pub method_name: String,
119    pub direction: SchemaCompatComparisonDirection,
120}
121
122#[derive(Facet, Clone, Debug)]
123pub struct SchemaCompatPolicy {
124    pub acknowledged_breaking: Vec<SchemaCompatAcknowledgement>,
125}
126
127#[derive(Facet, Clone, Debug)]
128pub struct SchemaCompatPolicyOutcome {
129    pub unacknowledged_breaking: Vec<SchemaCompatComparison>,
130    pub stale_acknowledgements: Vec<SchemaCompatAcknowledgement>,
131}
132
133impl SchemaCompatPolicyOutcome {
134    #[must_use]
135    pub fn is_ok(&self) -> bool {
136        self.unacknowledged_breaking.is_empty() && self.stale_acknowledgements.is_empty()
137    }
138}
139
140// r[impl schema.compat.snapshot]
141pub fn snapshot_services(
142    services: &[&'static ServiceDescriptor],
143) -> Result<SchemaCompatSnapshot, SchemaCompatError> {
144    let mut service_snapshots = Vec::with_capacity(services.len());
145    for service in services {
146        let mut methods = Vec::with_capacity(service.methods.len());
147        for method in service.methods {
148            let args = snapshot_root(
149                service.service_name,
150                method.method_name,
151                BindingDirection::Args,
152                method.args_shape,
153            )?;
154            let response = snapshot_root(
155                service.service_name,
156                method.method_name,
157                BindingDirection::Response,
158                method.response_wire_shape,
159            )?;
160            methods.push(MethodSchemaSnapshot {
161                method_name: method.method_name.to_string(),
162                method_id: hex_u64(method.id.0),
163                args,
164                response,
165            });
166        }
167        service_snapshots.push(ServiceSchemaSnapshot {
168            service_name: service.service_name.to_string(),
169            methods,
170        });
171    }
172    Ok(SchemaCompatSnapshot {
173        services: service_snapshots,
174    })
175}
176
177// r[impl schema.compat.check]
178// r[impl rpc.schema-evolution]
179pub fn compare_snapshots(
180    older: &SchemaCompatSnapshot,
181    newer: &SchemaCompatSnapshot,
182) -> Result<SchemaCompatReport, SchemaCompatError> {
183    let registry = registry_for_snapshots(older, newer)?;
184    let mut comparisons = Vec::new();
185
186    let old_services = service_map(older);
187    let new_services = service_map(newer);
188    let service_names = old_services
189        .keys()
190        .chain(new_services.keys())
191        .copied()
192        .collect::<BTreeSet<_>>();
193
194    for service_name in service_names {
195        match (
196            old_services.get(service_name).copied(),
197            new_services.get(service_name).copied(),
198        ) {
199            (Some(old_service), Some(new_service)) => {
200                compare_service(old_service, new_service, &registry, &mut comparisons)?;
201            }
202            (Some(_), None) => comparisons.push(SchemaCompatComparison {
203                service_name: service_name.to_string(),
204                method_name: "*".to_string(),
205                direction: SchemaCompatComparisonDirection::Method,
206                status: SchemaCompatStatus::Incompatible,
207                note: Some("service removed".to_string()),
208            }),
209            (None, Some(_)) => comparisons.push(SchemaCompatComparison {
210                service_name: service_name.to_string(),
211                method_name: "*".to_string(),
212                direction: SchemaCompatComparisonDirection::Method,
213                status: SchemaCompatStatus::Bidirectional,
214                note: Some("service added".to_string()),
215            }),
216            (None, None) => unreachable!("service name came from one of the maps"),
217        }
218    }
219
220    Ok(SchemaCompatReport { comparisons })
221}
222
223pub fn method_intersection_snapshot(
224    snapshot: &SchemaCompatSnapshot,
225    reference: &SchemaCompatSnapshot,
226) -> SchemaCompatSnapshot {
227    let reference_services = service_map(reference);
228    let services = snapshot
229        .services
230        .iter()
231        .filter_map(|service| {
232            let reference_service = reference_services.get(service.service_name.as_str())?;
233            let reference_methods = reference_service
234                .methods
235                .iter()
236                .map(|method| method.method_name.as_str())
237                .collect::<BTreeSet<_>>();
238            let methods = service
239                .methods
240                .iter()
241                .filter(|method| reference_methods.contains(method.method_name.as_str()))
242                .cloned()
243                .collect::<Vec<_>>();
244            Some(ServiceSchemaSnapshot {
245                service_name: service.service_name.clone(),
246                methods,
247            })
248        })
249        .collect();
250    SchemaCompatSnapshot { services }
251}
252
253// r[impl schema.compat.policy]
254#[must_use]
255pub fn enforce_policy(
256    report: &SchemaCompatReport,
257    policy: &SchemaCompatPolicy,
258) -> SchemaCompatPolicyOutcome {
259    let acknowledged = policy
260        .acknowledged_breaking
261        .iter()
262        .cloned()
263        .collect::<BTreeSet<_>>();
264    let actual_breaking = report
265        .comparisons
266        .iter()
267        .filter(|comparison| comparison.status == SchemaCompatStatus::Incompatible)
268        .map(|comparison| SchemaCompatAcknowledgement {
269            service_name: comparison.service_name.clone(),
270            method_name: comparison.method_name.clone(),
271            direction: comparison.direction,
272        })
273        .collect::<BTreeSet<_>>();
274
275    let unacknowledged_breaking = report
276        .comparisons
277        .iter()
278        .filter(|comparison| comparison.status == SchemaCompatStatus::Incompatible)
279        .filter(|comparison| {
280            !acknowledged.contains(&SchemaCompatAcknowledgement {
281                service_name: comparison.service_name.clone(),
282                method_name: comparison.method_name.clone(),
283                direction: comparison.direction,
284            })
285        })
286        .cloned()
287        .collect();
288
289    let stale_acknowledgements = acknowledged.difference(&actual_breaking).cloned().collect();
290
291    SchemaCompatPolicyOutcome {
292        unacknowledged_breaking,
293        stale_acknowledgements,
294    }
295}
296
297fn compare_service(
298    old_service: &ServiceSchemaSnapshot,
299    new_service: &ServiceSchemaSnapshot,
300    registry: &Registry,
301    comparisons: &mut Vec<SchemaCompatComparison>,
302) -> Result<(), SchemaCompatError> {
303    let old_methods = method_map(old_service);
304    let new_methods = method_map(new_service);
305    let method_names = old_methods
306        .keys()
307        .chain(new_methods.keys())
308        .copied()
309        .collect::<BTreeSet<_>>();
310
311    for method_name in method_names {
312        match (
313            old_methods.get(method_name).copied(),
314            new_methods.get(method_name).copied(),
315        ) {
316            (Some(old_method), Some(new_method)) => {
317                compare_root(
318                    &old_service.service_name,
319                    method_name,
320                    SchemaCompatComparisonDirection::Args,
321                    &old_method.args,
322                    &new_method.args,
323                    registry,
324                    comparisons,
325                )?;
326                compare_root(
327                    &old_service.service_name,
328                    method_name,
329                    SchemaCompatComparisonDirection::Response,
330                    &old_method.response,
331                    &new_method.response,
332                    registry,
333                    comparisons,
334                )?;
335            }
336            (Some(_), None) => comparisons.push(SchemaCompatComparison {
337                service_name: old_service.service_name.clone(),
338                method_name: method_name.to_string(),
339                direction: SchemaCompatComparisonDirection::Method,
340                status: SchemaCompatStatus::Incompatible,
341                note: Some("method removed".to_string()),
342            }),
343            (None, Some(_)) => comparisons.push(SchemaCompatComparison {
344                service_name: new_service.service_name.clone(),
345                method_name: method_name.to_string(),
346                direction: SchemaCompatComparisonDirection::Method,
347                status: SchemaCompatStatus::Bidirectional,
348                note: Some("method added".to_string()),
349            }),
350            (None, None) => unreachable!("method name came from one of the maps"),
351        }
352    }
353
354    Ok(())
355}
356
357fn compare_root(
358    service_name: &str,
359    method_name: &str,
360    direction: SchemaCompatComparisonDirection,
361    older: &SchemaRootSnapshot,
362    newer: &SchemaRootSnapshot,
363    registry: &Registry,
364    comparisons: &mut Vec<SchemaCompatComparison>,
365) -> Result<(), SchemaCompatError> {
366    let older_root = parse_schema_id(&older.root)?;
367    let newer_root = parse_schema_id(&newer.root)?;
368    comparisons.push(SchemaCompatComparison {
369        service_name: service_name.to_string(),
370        method_name: method_name.to_string(),
371        direction,
372        status: compatibility_direction(older_root, newer_root, registry).into(),
373        note: None,
374    });
375    Ok(())
376}
377
378fn snapshot_root(
379    service: &'static str,
380    method: &'static str,
381    direction: BindingDirection,
382    shape: &'static facet::Shape,
383) -> Result<SchemaRootSnapshot, SchemaCompatError> {
384    let closure =
385        vox_phon::schema_bytes_for_shape(shape).map_err(|error| SchemaCompatError::Snapshot {
386            service,
387            method,
388            direction,
389            message: error.to_string(),
390        })?;
391    let bundle =
392        vox_phon::parse_schema_bytes(&closure).map_err(|error| SchemaCompatError::Snapshot {
393            service,
394            method,
395            direction,
396            message: error.to_string(),
397        })?;
398    Ok(SchemaRootSnapshot {
399        root: hex_u64(bundle.root.as_u64()),
400        closure: hex_bytes(&closure),
401    })
402}
403
404fn registry_for_snapshots(
405    older: &SchemaCompatSnapshot,
406    newer: &SchemaCompatSnapshot,
407) -> Result<Registry, SchemaCompatError> {
408    let mut schemas = BTreeMap::<u64, Schema>::new();
409    for root in roots(older).chain(roots(newer)) {
410        let closure = parse_hex(&root.closure)?;
411        let bundle = vox_phon::parse_schema_bytes(&closure)
412            .map_err(|error| SchemaCompatError::SchemaClosure(error.to_string()))?;
413        let expected_root = parse_schema_id(&root.root)?;
414        if bundle.root != expected_root {
415            return Err(SchemaCompatError::SchemaClosure(format!(
416                "schema closure root {} did not match snapshot root {}",
417                hex_u64(bundle.root.as_u64()),
418                root.root
419            )));
420        }
421        for schema in bundle.schemas {
422            schemas.entry(schema.id.as_u64()).or_insert(schema);
423        }
424    }
425    Ok(Registry::new(schemas.into_values()))
426}
427
428fn roots(snapshot: &SchemaCompatSnapshot) -> impl Iterator<Item = &SchemaRootSnapshot> {
429    snapshot.services.iter().flat_map(|service| {
430        service
431            .methods
432            .iter()
433            .flat_map(|method| [&method.args, &method.response])
434    })
435}
436
437fn service_map(snapshot: &SchemaCompatSnapshot) -> BTreeMap<&str, &ServiceSchemaSnapshot> {
438    snapshot
439        .services
440        .iter()
441        .map(|service| (service.service_name.as_str(), service))
442        .collect()
443}
444
445fn method_map(service: &ServiceSchemaSnapshot) -> BTreeMap<&str, &MethodSchemaSnapshot> {
446    service
447        .methods
448        .iter()
449        .map(|method| (method.method_name.as_str(), method))
450        .collect()
451}
452
453fn parse_schema_id(hex: &str) -> Result<SchemaId, SchemaCompatError> {
454    let trimmed = hex.strip_prefix("0x").unwrap_or(hex);
455    u64::from_str_radix(trimmed, 16)
456        .map(SchemaId::from_raw)
457        .map_err(|error| SchemaCompatError::Hex(format!("invalid schema id {hex:?}: {error}")))
458}
459
460fn parse_hex(hex: &str) -> Result<Vec<u8>, SchemaCompatError> {
461    let trimmed = hex.strip_prefix("0x").unwrap_or(hex);
462    if !trimmed.len().is_multiple_of(2) {
463        return Err(SchemaCompatError::Hex(format!(
464            "hex string has odd length: {hex:?}"
465        )));
466    }
467    let mut out = Vec::with_capacity(trimmed.len() / 2);
468    let bytes = trimmed.as_bytes();
469    for pair in bytes.chunks_exact(2) {
470        let hi = hex_nibble(pair[0])?;
471        let lo = hex_nibble(pair[1])?;
472        out.push((hi << 4) | lo);
473    }
474    Ok(out)
475}
476
477fn hex_nibble(byte: u8) -> Result<u8, SchemaCompatError> {
478    match byte {
479        b'0'..=b'9' => Ok(byte - b'0'),
480        b'a'..=b'f' => Ok(byte - b'a' + 10),
481        b'A'..=b'F' => Ok(byte - b'A' + 10),
482        _ => Err(SchemaCompatError::Hex(format!(
483            "invalid hex digit {:?}",
484            char::from(byte)
485        ))),
486    }
487}
488
489fn hex_u64(value: u64) -> String {
490    format!("{value:016x}")
491}
492
493fn hex_bytes(bytes: &[u8]) -> String {
494    let mut out = String::with_capacity(bytes.len() * 2);
495    for byte in bytes {
496        out.push_str(&format!("{byte:02x}"));
497    }
498    out
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    fn testbed_policy() -> SchemaCompatPolicy {
506        SchemaCompatPolicy {
507            acknowledged_breaking: vec![
508                SchemaCompatAcknowledgement {
509                    service_name: "Testbed".to_string(),
510                    method_name: "echo_measurement".to_string(),
511                    direction: SchemaCompatComparisonDirection::Args,
512                },
513                SchemaCompatAcknowledgement {
514                    service_name: "Testbed".to_string(),
515                    method_name: "echo_measurement".to_string(),
516                    direction: SchemaCompatComparisonDirection::Response,
517                },
518            ],
519        }
520    }
521
522    fn evolved_testbed_snapshots() -> (SchemaCompatSnapshot, SchemaCompatSnapshot) {
523        let old_services = spec_proto::all_services();
524        let old = snapshot_services(&old_services).expect("old snapshot");
525        let evolved_services = [spec_proto::evolved::testbed_service_descriptor()];
526        let new = snapshot_services(&evolved_services).expect("new snapshot");
527        (method_intersection_snapshot(&old, &new), new)
528    }
529
530    fn snapshot_without_method(
531        snapshot: &SchemaCompatSnapshot,
532        service_name: &str,
533        method_name: &str,
534    ) -> SchemaCompatSnapshot {
535        let mut snapshot = snapshot.clone();
536        let service = snapshot
537            .services
538            .iter_mut()
539            .find(|service| service.service_name == service_name)
540            .expect("service in snapshot");
541        let before = service.methods.len();
542        service
543            .methods
544            .retain(|method| method.method_name != method_name);
545        assert_eq!(
546            service.methods.len(),
547            before - 1,
548            "method {service_name}.{method_name} should exist in snapshot"
549        );
550        snapshot
551    }
552
553    // r[verify schema.compat.snapshot]
554    #[test]
555    fn snapshots_include_method_args_and_response_roots() {
556        let services = spec_proto::all_services();
557        let snapshot = snapshot_services(&services).expect("snapshot services");
558        let testbed = snapshot
559            .services
560            .iter()
561            .find(|service| service.service_name == "Testbed")
562            .expect("testbed service");
563        let echo_profile = testbed
564            .methods
565            .iter()
566            .find(|method| method.method_name == "echo_profile")
567            .expect("echo_profile method");
568
569        assert!(!echo_profile.args.root.is_empty());
570        assert!(!echo_profile.args.closure.is_empty());
571        assert!(!echo_profile.response.root.is_empty());
572        assert!(!echo_profile.response.closure.is_empty());
573    }
574
575    // r[verify schema.compat.check]
576    // r[verify rpc.schema-evolution]
577    #[test]
578    fn compares_evolved_testbed_schema_directions() {
579        let (old, new) = evolved_testbed_snapshots();
580        let report = compare_snapshots(&old, &new).expect("compat report");
581
582        let echo_profile_args = report
583            .comparisons
584            .iter()
585            .find(|comparison| {
586                comparison.method_name == "echo_profile"
587                    && comparison.direction == SchemaCompatComparisonDirection::Args
588            })
589            .expect("echo_profile args comparison");
590        assert_eq!(echo_profile_args.status, SchemaCompatStatus::Bidirectional);
591
592        let echo_measurement_args = report
593            .comparisons
594            .iter()
595            .find(|comparison| {
596                comparison.method_name == "echo_measurement"
597                    && comparison.direction == SchemaCompatComparisonDirection::Args
598            })
599            .expect("echo_measurement args comparison");
600        assert_eq!(
601            echo_measurement_args.status,
602            SchemaCompatStatus::Incompatible
603        );
604    }
605
606    // r[verify rpc.schema-evolution]
607    #[test]
608    fn compare_reports_added_methods_as_compatible() {
609        let services = spec_proto::all_services();
610        let new = snapshot_services(&services).expect("new snapshot");
611        let old = snapshot_without_method(&new, "Testbed", "echo_profile");
612        let report = compare_snapshots(&old, &new).expect("compat report");
613
614        let added_method = report
615            .comparisons
616            .iter()
617            .find(|comparison| {
618                comparison.service_name == "Testbed"
619                    && comparison.method_name == "echo_profile"
620                    && comparison.direction == SchemaCompatComparisonDirection::Method
621            })
622            .expect("added method comparison");
623        assert_eq!(added_method.status, SchemaCompatStatus::Bidirectional);
624        assert_eq!(added_method.note.as_deref(), Some("method added"));
625    }
626
627    // r[verify rpc.schema-evolution]
628    #[test]
629    fn compare_reports_removed_methods_as_breaking() {
630        let services = spec_proto::all_services();
631        let old = snapshot_services(&services).expect("old snapshot");
632        let new = snapshot_without_method(&old, "Testbed", "echo_profile");
633        let report = compare_snapshots(&old, &new).expect("compat report");
634
635        let removed_method = report
636            .comparisons
637            .iter()
638            .find(|comparison| {
639                comparison.service_name == "Testbed"
640                    && comparison.method_name == "echo_profile"
641                    && comparison.direction == SchemaCompatComparisonDirection::Method
642            })
643            .expect("removed method comparison");
644        assert_eq!(removed_method.status, SchemaCompatStatus::Incompatible);
645        assert_eq!(removed_method.note.as_deref(), Some("method removed"));
646    }
647
648    // r[verify schema.compat.policy]
649    #[test]
650    fn policy_requires_acknowledging_breaking_changes() {
651        let (old, new) = evolved_testbed_snapshots();
652        let report = compare_snapshots(&old, &new).expect("compat report");
653
654        let rejected = enforce_policy(
655            &report,
656            &SchemaCompatPolicy {
657                acknowledged_breaking: Vec::new(),
658            },
659        );
660        assert!(!rejected.is_ok());
661        assert!(!rejected.unacknowledged_breaking.is_empty());
662
663        let accepted = enforce_policy(&report, &testbed_policy());
664        assert!(accepted.is_ok(), "{accepted:?}");
665    }
666}