Skip to main content

workshop_rs_cli/
live_capture.rs

1//! Offline schema and comparison support for recorded client captures.
2
3use std::collections::{BTreeMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7pub use super::census::{CENSUS_IDENTITY_SCHEMA_VERSION, CensusIdentity};
8use super::conformance::{
9    ConformanceResult, ConformanceStatus, Equivalence, FeatureId, TestArtifact, is_sha256_digest,
10};
11use workshop_rs::catalog::{Catalog, CatalogIdentity, Locale};
12
13/// The current machine-readable live-capture schema version.
14pub const LIVE_CAPTURE_SCHEMA_VERSION: u32 = 1;
15
16/// One machine-readable capture from a manually operated Workshop client.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct LiveCapture {
20    pub schema_version: u32,
21    pub capture_id: String,
22    pub game: String,
23    /// The client version/build string as observed by the maintainer.
24    pub client: String,
25    pub season: String,
26    pub captured_at: String,
27    /// Environment notes, including platform and any conditions relevant to
28    /// interpreting import/export behavior.
29    pub environment: String,
30    pub locale: Locale,
31    pub catalog: CatalogIdentity,
32    pub census: CensusIdentity,
33    /// The exact exported Workshop text or archive, pinned by revision/path
34    /// and SHA-256. The bytes are intentionally not embedded in this schema.
35    pub raw_artifact: TestArtifact,
36    /// Feature-attributed results for the captured probes.
37    pub results: Vec<ConformanceResult>,
38}
39
40impl LiveCapture {
41    /// Validate a capture's structural and provenance contract. Historical
42    /// captures may refer to an older catalog identity, so this does not
43    /// silently substitute the current bundled catalog.
44    pub fn validate(&self) -> Result<(), LiveCaptureError> {
45        self.validate_structural(None)
46    }
47
48    /// Validate a current capture against the loaded canonical catalog.
49    pub fn validate_against(&self, catalog: &Catalog) -> Result<(), LiveCaptureError> {
50        self.validate_structural(Some(catalog))
51    }
52
53    fn validate_structural(&self, catalog: Option<&Catalog>) -> Result<(), LiveCaptureError> {
54        if self.schema_version != LIVE_CAPTURE_SCHEMA_VERSION {
55            return Err(invalid(format!(
56                "unsupported live capture schema version {}; expected {}",
57                self.schema_version, LIVE_CAPTURE_SCHEMA_VERSION
58            )));
59        }
60        validate_name("captureId", &self.capture_id)?;
61        validate_name("game", &self.game)?;
62        validate_name("client", &self.client)?;
63        validate_name("season", &self.season)?;
64        validate_timestamp(&self.captured_at)?;
65        validate_name("environment", &self.environment)?;
66        validate_name("locale", self.locale.as_str())?;
67        validate_catalog(&self.catalog)?;
68        if normalize_game(&self.catalog.target.game) != normalize_game(&self.game) {
69            return Err(invalid("capture game does not match catalog target game"));
70        }
71        if let Some(catalog) = catalog
72            && self.catalog != catalog.identity()
73        {
74            return Err(invalid(
75                "capture catalog identity does not match the loaded catalog",
76            ));
77        }
78        validate_census(&self.census)?;
79        validate_artifact("rawArtifact", &self.raw_artifact, true)?;
80        if self.results.is_empty() {
81            return Err(invalid(
82                "results must contain at least one conformance result",
83            ));
84        }
85
86        let mut case_ids = HashSet::with_capacity(self.results.len());
87        for (index, result) in self.results.iter().enumerate() {
88            let validation = match catalog {
89                Some(catalog) => result.validate_against(catalog),
90                None => result.validate(),
91            };
92            validation.map_err(|error| invalid(format!("results[{index}]: {error}")))?;
93            if !case_ids.insert(&result.case_id) {
94                return Err(invalid(format!(
95                    "results[{index}].caseId duplicates another capture result"
96                )));
97            }
98            if result.catalog != self.catalog {
99                return Err(invalid(format!(
100                    "results[{index}].catalog does not match capture catalog"
101                )));
102            }
103            if result.locale.as_ref() != Some(&self.locale) {
104                return Err(invalid(format!(
105                    "results[{index}].locale does not match capture locale"
106                )));
107            }
108            if result.source != self.raw_artifact {
109                return Err(invalid(format!(
110                    "results[{index}].source must pin the capture raw artifact"
111                )));
112            }
113            if result.status == ConformanceStatus::Matched {
114                let observed = result.comparison.observed.as_ref().ok_or_else(|| {
115                    invalid(format!(
116                        "results[{index}].comparison.observed is required for matched results"
117                    ))
118                })?;
119                if observed.sha256 != self.raw_artifact.sha256 {
120                    return Err(invalid(format!(
121                        "results[{index}].comparison.observed must pin the raw capture digest"
122                    )));
123                }
124            }
125        }
126        Ok(())
127    }
128
129    /// Deserialize and validate a JSON capture in one operation.
130    pub fn from_json(json: &str) -> Result<Self, LiveCaptureError> {
131        let capture: Self = serde_json::from_str(json)
132            .map_err(|error| invalid(format!("invalid live capture JSON: {error}")))?;
133        capture.validate()?;
134        Ok(capture)
135    }
136
137    /// Serialize a validated capture as stable, human-reviewable JSON.
138    pub fn to_json(&self) -> Result<String, LiveCaptureError> {
139        self.validate()?;
140        serde_json::to_string_pretty(self)
141            .map_err(|error| invalid(format!("cannot serialize live capture: {error}")))
142    }
143
144    /// Compare two validated captures without contacting a provider or client.
145    pub fn diff(&self, newer: &Self) -> Result<LiveCaptureDiff, LiveCaptureError> {
146        self.validate()?;
147        newer.validate()?;
148
149        let mut changes = Vec::new();
150        if self.locale != newer.locale {
151            changes.push(DiffEntry::metadata(
152                DiffCategory::Locale,
153                format!("locale changed from {} to {}", self.locale, newer.locale),
154            ));
155        }
156        if self.catalog != newer.catalog {
157            changes.push(DiffEntry::metadata(
158                DiffCategory::Catalog,
159                "catalog identity changed",
160            ));
161        }
162        if self.census != newer.census {
163            changes.push(DiffEntry::metadata(
164                DiffCategory::SemanticSchema,
165                "census identity or shard set changed",
166            ));
167        }
168        if self.raw_artifact != newer.raw_artifact {
169            changes.push(DiffEntry::metadata(
170                DiffCategory::Content,
171                "raw client artifact provenance or content changed",
172            ));
173        }
174
175        let prior: BTreeMap<_, _> = self
176            .results
177            .iter()
178            .map(|result| (result.case_id.as_str(), result))
179            .collect();
180        let current: BTreeMap<_, _> = newer
181            .results
182            .iter()
183            .map(|result| (result.case_id.as_str(), result))
184            .collect();
185        let mut all_case_ids: Vec<&str> = prior.keys().chain(current.keys()).copied().collect();
186        all_case_ids.sort_unstable();
187        all_case_ids.dedup();
188
189        let mut runtime_uncertainty = vec![DiffEntry::metadata(
190            DiffCategory::RuntimeUncertainty,
191            "import/export capture does not establish gameplay or runtime behavior",
192        )];
193        for case_id in all_case_ids {
194            match (prior.get(case_id), current.get(case_id)) {
195                (None, Some(result)) => changes.push(DiffEntry::result(
196                    DiffCategory::Content,
197                    result,
198                    "feature-attributed result was added",
199                )),
200                (Some(result), None) => changes.push(DiffEntry::result(
201                    DiffCategory::Content,
202                    result,
203                    "feature-attributed result was removed",
204                )),
205                (Some(previous), Some(current)) => {
206                    let features_changed = !same_features(previous, current);
207                    if features_changed {
208                        changes.push(DiffEntry::result(
209                            DiffCategory::SemanticSchema,
210                            current,
211                            "feature attribution changed",
212                        ));
213                    }
214
215                    if previous.status != current.status {
216                        if is_runtime_uncertain(previous) {
217                            runtime_uncertainty.push(DiffEntry::result(
218                                DiffCategory::RuntimeUncertainty,
219                                current,
220                                "result is not a comparable semantic match",
221                            ));
222                        } else {
223                            changes.push(DiffEntry::result(
224                                DiffCategory::SemanticSchema,
225                                current,
226                                format!(
227                                    "conformance status changed from {:?} to {:?}",
228                                    previous.status, current.status
229                                ),
230                            ));
231                        }
232                    } else if is_runtime_uncertain(current) {
233                        runtime_uncertainty.push(DiffEntry::result(
234                            DiffCategory::RuntimeUncertainty,
235                            current,
236                            "result is not a comparable semantic match",
237                        ));
238                    } else if previous.comparison.mode != current.comparison.mode {
239                        changes.push(DiffEntry::result(
240                            DiffCategory::SemanticSchema,
241                            current,
242                            "comparison mode changed",
243                        ));
244                    } else if previous.comparison != current.comparison {
245                        changes.push(DiffEntry::result(
246                            DiffCategory::Content,
247                            current,
248                            "expected or observed feature artifact changed",
249                        ));
250                    }
251                }
252                (None, None) => unreachable!("case ID came from one of the result maps"),
253            }
254        }
255
256        Ok(LiveCaptureDiff {
257            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
258            prior_capture_id: self.capture_id.clone(),
259            new_capture_id: newer.capture_id.clone(),
260            changes,
261            runtime_uncertainty,
262        })
263    }
264}
265
266/// The classification used by the offline capture comparison.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum DiffCategory {
270    Locale,
271    Catalog,
272    Content,
273    SemanticSchema,
274    RuntimeUncertainty,
275}
276
277/// One feature-attributed or capture-level diff observation.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct DiffEntry {
281    pub category: DiffCategory,
282    pub case_id: Option<String>,
283    pub features: Vec<FeatureId>,
284    pub detail: String,
285}
286
287impl DiffEntry {
288    fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
289        Self {
290            category,
291            case_id: None,
292            features: Vec::new(),
293            detail: detail.into(),
294        }
295    }
296
297    fn result(
298        category: DiffCategory,
299        result: &ConformanceResult,
300        detail: impl Into<String>,
301    ) -> Self {
302        Self {
303            category,
304            case_id: Some(result.case_id.clone()),
305            features: result.features.clone(),
306            detail: detail.into(),
307        }
308    }
309}
310
311/// Machine-readable output of [`LiveCapture::diff`].
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct LiveCaptureDiff {
315    pub schema_version: u32,
316    pub prior_capture_id: String,
317    pub new_capture_id: String,
318    pub changes: Vec<DiffEntry>,
319    /// Runtime/gameplay uncertainty is intentionally separate from
320    /// import/export changes and is always present for this workflow.
321    pub runtime_uncertainty: Vec<DiffEntry>,
322}
323
324impl LiveCaptureDiff {
325    pub fn to_json(&self) -> Result<String, LiveCaptureError> {
326        serde_json::to_string_pretty(self)
327            .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
328    }
329
330    pub fn human_summary(&self) -> String {
331        let mut output = format!(
332            "live capture diff schema {}\n{} -> {}\n",
333            self.schema_version, self.prior_capture_id, self.new_capture_id
334        );
335        for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
336            output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
337        }
338        if self.changes.is_empty() {
339            output.push_str("no import/export changes classified\n");
340        }
341        output
342    }
343}
344
345/// A validation or serialization failure in the offline capture workflow.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct LiveCaptureError {
348    pub message: String,
349}
350
351impl std::fmt::Display for LiveCaptureError {
352    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        formatter.write_str(&self.message)
354    }
355}
356
357impl std::error::Error for LiveCaptureError {}
358
359fn invalid(message: impl Into<String>) -> LiveCaptureError {
360    LiveCaptureError {
361        message: message.into(),
362    }
363}
364
365fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
366    if value.trim().is_empty() || value.chars().any(char::is_control) {
367        Err(invalid(format!("{field} must be non-empty and printable")))
368    } else {
369        Ok(())
370    }
371}
372
373fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
374    validate_name("capturedAt", value)?;
375    // RFC-3339 requires a `T` separator and either `Z`/`z` or a `+HH:MM`/
376    // `-HH:MM` offset — a `-` inside the date part must not count, so only
377    // the portion after `T` is inspected for the offset sign.
378    let Some(time) = value.split_once('T').map(|(_, time)| time) else {
379        return Err(invalid(
380            "capturedAt must use an ISO-8601 timestamp with a timezone",
381        ));
382    };
383    let has_offset = time.ends_with('Z') || time.ends_with('z') || time.contains(['+', '-']);
384    if !has_offset {
385        return Err(invalid(
386            "capturedAt must use an ISO-8601 timestamp with a timezone",
387        ));
388    }
389    Ok(())
390}
391
392fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
393    validate_name(
394        "catalog.implementationVersion",
395        &catalog.implementation_version,
396    )?;
397    validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
398    let digest = catalog
399        .catalog_digest
400        .as_deref()
401        .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
402    validate_sha256("catalog.catalogDigest", digest)?;
403    validate_name("catalog.target.game", &catalog.target.game)?;
404    validate_name("catalog.target.format", &catalog.target.format)?;
405    validate_name("catalog.target.surface", &catalog.target.surface)?;
406    if catalog.locale_coverage.is_empty() {
407        return Err(invalid("catalog.localeCoverage must not be empty"));
408    }
409    Ok(())
410}
411
412fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
413    if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
414        return Err(invalid(format!(
415            "unsupported census identity schema version {}; expected {}",
416            census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
417        )));
418    }
419    validate_sha256("census.digest", &census.digest)?;
420    if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
421        return Err(invalid(
422            "census.shards must be non-empty and strictly sorted",
423        ));
424    }
425    Ok(())
426}
427
428fn validate_artifact(
429    field: &str,
430    artifact: &TestArtifact,
431    require_pin: bool,
432) -> Result<(), LiveCaptureError> {
433    validate_name(&format!("{field}.name"), &artifact.name)?;
434    if require_pin {
435        validate_name(
436            &format!("{field}.revision"),
437            artifact.revision.as_deref().unwrap_or_default(),
438        )?;
439        validate_name(
440            &format!("{field}.path"),
441            artifact.path.as_deref().unwrap_or_default(),
442        )?;
443        validate_sha256(
444            &format!("{field}.sha256"),
445            artifact.sha256.as_deref().unwrap_or_default(),
446        )?;
447    }
448    Ok(())
449}
450
451fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
452    if !is_sha256_digest(digest) {
453        return Err(invalid(format!(
454            "{field} must be a 64-character hexadecimal SHA-256 digest"
455        )));
456    }
457    Ok(())
458}
459
460fn normalize_game(value: &str) -> String {
461    value
462        .chars()
463        .filter(char::is_ascii_alphanumeric)
464        .flat_map(char::to_lowercase)
465        .collect()
466}
467
468fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
469    left.features.len() == right.features.len()
470        && left
471            .features
472            .iter()
473            .all(|feature| right.features.contains(feature))
474}
475
476fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
477    !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
478}
479
480#[cfg(test)]
481mod tests {
482    use super::super::conformance::{
483        CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
484        ReasonCode,
485    };
486    use super::*;
487    use workshop_rs::catalog::Catalog;
488
489    const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
490    const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
491
492    fn catalog() -> CatalogIdentity {
493        Catalog::builtin()
494            .expect("built-in catalog for constructed unit data")
495            .identity()
496    }
497
498    fn raw(digest: &str) -> TestArtifact {
499        TestArtifact {
500            name: "constructed-unit-test/raw.ws".to_string(),
501            revision: Some("unit-test-revision".to_string()),
502            path: Some("constructed-unit-test/raw.ws".to_string()),
503            sha256: Some(digest.to_string()),
504            license: Some("MIT".to_string()),
505        }
506    }
507
508    fn feature(name: &str) -> FeatureId {
509        FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
510            .expect("constructed feature identity")
511    }
512
513    fn result(
514        identity: &CatalogIdentity,
515        locale: &Locale,
516        raw: &TestArtifact,
517        case_id: &str,
518        status: ConformanceStatus,
519        feature_name: &str,
520    ) -> ConformanceResult {
521        let matched = status == ConformanceStatus::Matched;
522        ConformanceResult {
523            schema_version: CONFORMANCE_SCHEMA_VERSION,
524            case_id: case_id.to_string(),
525            features: vec![feature(feature_name)],
526            status,
527            comparison: Comparison {
528                mode: if matched {
529                    Equivalence::Normalized
530                } else {
531                    Equivalence::NotComparable
532                },
533                expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
534                observed: matched.then(|| TestArtifact {
535                    name: "constructed-unit-test/observed.ws".to_string(),
536                    revision: Some("unit-test-revision".to_string()),
537                    path: Some("constructed-unit-test/raw.ws".to_string()),
538                    sha256: Some(raw.sha256.clone().unwrap()),
539                    license: Some("MIT".to_string()),
540                }),
541                normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
542            },
543            source: raw.clone(),
544            catalog: identity.clone(),
545            locale: Some(locale.clone()),
546            reason: (!matched).then(|| ConformanceReason {
547                code: ReasonCode::Inconclusive,
548                detail: "constructed unit uncertainty".to_string(),
549            }),
550        }
551    }
552
553    fn make_capture(
554        capture_id: &str,
555        locale: &str,
556        digest: &str,
557        result_status: ConformanceStatus,
558        feature_name: &str,
559    ) -> LiveCapture {
560        let identity = catalog();
561        let locale = Locale::new(locale);
562        let raw = raw(digest);
563        LiveCapture {
564            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
565            capture_id: capture_id.to_string(),
566            game: "overwatch-2".to_string(),
567            client: "constructed-unit-test-client".to_string(),
568            season: "constructed-unit-test-season".to_string(),
569            captured_at: "2026-08-18T00:00:00Z".to_string(),
570            environment: "constructed schema/diff unit test; not a client capture".to_string(),
571            locale: locale.clone(),
572            catalog: identity.clone(),
573            census: CensusIdentity {
574                schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
575                digest: DIGEST.to_string(),
576                shards: vec!["constructed-unit-test-shard".to_string()],
577            },
578            raw_artifact: raw.clone(),
579            results: vec![result(
580                &identity,
581                &locale,
582                &raw,
583                "constructed-unit-test/case",
584                result_status,
585                feature_name,
586            )],
587        }
588    }
589
590    #[test]
591    fn constructed_capture_schema_round_trips_without_runtime_claim() {
592        let capture = make_capture(
593            "capture-a",
594            "en-US",
595            DIGEST,
596            ConformanceStatus::Matched,
597            "one",
598        );
599        let json = capture.to_json().expect("constructed schema serializes");
600        let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
601        assert_eq!(decoded, capture);
602    }
603
604    #[test]
605    fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
606        let mut capture = make_capture(
607            "capture-a",
608            "en-US",
609            DIGEST,
610            ConformanceStatus::Matched,
611            "one",
612        );
613        capture.raw_artifact.sha256 = None;
614        assert!(capture.validate().is_err());
615
616        let mut capture = make_capture(
617            "capture-a",
618            "en-US",
619            DIGEST,
620            ConformanceStatus::Matched,
621            "one",
622        );
623        capture.game = "not-overwatch".to_string();
624        assert!(capture.validate().is_err());
625
626        let mut capture = make_capture(
627            "capture-a",
628            "en-US",
629            DIGEST,
630            ConformanceStatus::Matched,
631            "one",
632        );
633        capture.results[0].source.name = "different source".to_string();
634        assert!(capture.validate().is_err());
635    }
636
637    #[test]
638    fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
639        let prior = make_capture(
640            "capture-a",
641            "en-US",
642            DIGEST,
643            ConformanceStatus::Matched,
644            "one",
645        );
646        let mut newer = make_capture(
647            "capture-b",
648            "zh-CN",
649            OTHER_DIGEST,
650            ConformanceStatus::Inconclusive,
651            "two",
652        );
653        newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
654        newer.results[0].catalog = newer.catalog.clone();
655        let diff = prior.diff(&newer).expect("constructed diff validates");
656        let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
657        assert!(categories.contains(&DiffCategory::Locale));
658        assert!(categories.contains(&DiffCategory::Catalog));
659        assert!(categories.contains(&DiffCategory::Content));
660        assert!(categories.contains(&DiffCategory::SemanticSchema));
661        assert!(
662            diff.changes
663                .iter()
664                .any(|entry| entry.detail.contains("conformance status changed")),
665            "status drift from a comparable baseline must be a change: {:?}",
666            diff.changes
667        );
668        assert!(
669            diff.runtime_uncertainty
670                .iter()
671                .all(|entry| !entry.detail.contains("comparable semantic match")),
672            "a certain baseline reports drift as a change, not uncertainty"
673        );
674        let json = diff.to_json().expect("structured diff serializes");
675        let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
676        assert!(document["changes"].is_array());
677        assert!(document["runtimeUncertainty"].is_array());
678    }
679
680    /// C2 companion: drift *from* an uncertain baseline is unverifiable and
681    /// lands in `runtime_uncertainty`, not `changes`.
682    #[test]
683    fn diff_reports_uncertainty_when_baseline_not_comparable() {
684        let prior = make_capture(
685            "capture-a",
686            "en-US",
687            DIGEST,
688            ConformanceStatus::Inconclusive,
689            "one",
690        );
691        let newer = make_capture(
692            "capture-b",
693            "en-US",
694            DIGEST,
695            ConformanceStatus::Matched,
696            "one",
697        );
698        let diff = prior.diff(&newer).expect("constructed diff validates");
699        assert!(
700            diff.runtime_uncertainty
701                .iter()
702                .any(|entry| entry.category == DiffCategory::RuntimeUncertainty),
703            "an uncertain baseline cannot attribute drift to a real change"
704        );
705        assert!(
706            !diff
707                .changes
708                .iter()
709                .any(|entry| entry.detail.contains("conformance status changed")),
710            "status drift on an uncertain baseline is not a change: {:?}",
711            diff.changes
712        );
713    }
714
715    /// C3: RFC-3339 offsets other than `Z`/`+` must validate, and a `+`
716    /// anywhere outside the time part must not count as an offset.
717    #[test]
718    fn timestamp_accepts_rfc3339_offsets() {
719        for valid in [
720            "2026-08-18T00:00:00Z",
721            "2026-08-18T00:00:00z",
722            "2026-08-18T00:00:00-08:00",
723            "2026-08-18T00:00:00+08:00",
724        ] {
725            validate_timestamp(valid).unwrap_or_else(|_| panic!("{valid} must validate"));
726        }
727        for invalid in ["2026-08-18", "2026-08-18T00:00:00", "not+a+timestamp"] {
728            assert!(validate_timestamp(invalid).is_err(), "{invalid} must fail");
729        }
730    }
731
732    #[test]
733    fn diff_refuses_capture_with_invalid_result_metadata() {
734        let prior = make_capture(
735            "capture-a",
736            "en-US",
737            DIGEST,
738            ConformanceStatus::Matched,
739            "one",
740        );
741        let mut newer = make_capture(
742            "capture-b",
743            "en-US",
744            DIGEST,
745            ConformanceStatus::Matched,
746            "one",
747        );
748        newer.results[0].catalog.catalog_digest = Some("different".to_string());
749        assert!(prior.diff(&newer).is_err());
750    }
751}