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            if self.catalog != catalog.identity() {
73                return Err(invalid(
74                    "capture catalog identity does not match the loaded catalog",
75                ));
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                            // The baseline was never comparable, so a status
218                            // drift cannot be attributed to a real change.
219                            runtime_uncertainty.push(DiffEntry::result(
220                                DiffCategory::RuntimeUncertainty,
221                                current,
222                                "result is not a comparable semantic match",
223                            ));
224                        } else {
225                            changes.push(DiffEntry::result(
226                                DiffCategory::SemanticSchema,
227                                current,
228                                format!(
229                                    "conformance status changed from {:?} to {:?}",
230                                    previous.status, current.status
231                                ),
232                            ));
233                        }
234                    } else if is_runtime_uncertain(current) {
235                        runtime_uncertainty.push(DiffEntry::result(
236                            DiffCategory::RuntimeUncertainty,
237                            current,
238                            "result is not a comparable semantic match",
239                        ));
240                    } else if previous.comparison.mode != current.comparison.mode {
241                        changes.push(DiffEntry::result(
242                            DiffCategory::SemanticSchema,
243                            current,
244                            "comparison mode changed",
245                        ));
246                    } else if previous.comparison != current.comparison {
247                        changes.push(DiffEntry::result(
248                            DiffCategory::Content,
249                            current,
250                            "expected or observed feature artifact changed",
251                        ));
252                    }
253                }
254                (None, None) => unreachable!("case ID came from one of the result maps"),
255            }
256        }
257
258        Ok(LiveCaptureDiff {
259            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
260            prior_capture_id: self.capture_id.clone(),
261            new_capture_id: newer.capture_id.clone(),
262            changes,
263            runtime_uncertainty,
264        })
265    }
266}
267
268/// The classification used by the offline capture comparison.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub enum DiffCategory {
272    Locale,
273    Catalog,
274    Content,
275    SemanticSchema,
276    RuntimeUncertainty,
277}
278
279/// One feature-attributed or capture-level diff observation.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct DiffEntry {
283    pub category: DiffCategory,
284    pub case_id: Option<String>,
285    pub features: Vec<FeatureId>,
286    pub detail: String,
287}
288
289impl DiffEntry {
290    fn metadata(category: DiffCategory, detail: impl Into<String>) -> Self {
291        Self {
292            category,
293            case_id: None,
294            features: Vec::new(),
295            detail: detail.into(),
296        }
297    }
298
299    fn result(
300        category: DiffCategory,
301        result: &ConformanceResult,
302        detail: impl Into<String>,
303    ) -> Self {
304        Self {
305            category,
306            case_id: Some(result.case_id.clone()),
307            features: result.features.clone(),
308            detail: detail.into(),
309        }
310    }
311}
312
313/// Machine-readable output of [`LiveCapture::diff`].
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct LiveCaptureDiff {
317    pub schema_version: u32,
318    pub prior_capture_id: String,
319    pub new_capture_id: String,
320    pub changes: Vec<DiffEntry>,
321    /// Runtime/gameplay uncertainty is intentionally separate from
322    /// import/export changes and is always present for this workflow.
323    pub runtime_uncertainty: Vec<DiffEntry>,
324}
325
326impl LiveCaptureDiff {
327    pub fn to_json(&self) -> Result<String, LiveCaptureError> {
328        serde_json::to_string_pretty(self)
329            .map_err(|error| invalid(format!("cannot serialize live capture diff: {error}")))
330    }
331
332    pub fn human_summary(&self) -> String {
333        let mut output = format!(
334            "live capture diff schema {}\n{} -> {}\n",
335            self.schema_version, self.prior_capture_id, self.new_capture_id
336        );
337        for entry in self.changes.iter().chain(self.runtime_uncertainty.iter()) {
338            output.push_str(&format!("{:?}: {}\n", entry.category, entry.detail));
339        }
340        if self.changes.is_empty() {
341            output.push_str("no import/export changes classified\n");
342        }
343        output
344    }
345}
346
347/// A validation or serialization failure in the offline capture workflow.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct LiveCaptureError {
350    pub message: String,
351}
352
353impl std::fmt::Display for LiveCaptureError {
354    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355        formatter.write_str(&self.message)
356    }
357}
358
359impl std::error::Error for LiveCaptureError {}
360
361fn invalid(message: impl Into<String>) -> LiveCaptureError {
362    LiveCaptureError {
363        message: message.into(),
364    }
365}
366
367fn validate_name(field: &str, value: &str) -> Result<(), LiveCaptureError> {
368    if value.trim().is_empty() || value.chars().any(char::is_control) {
369        Err(invalid(format!("{field} must be non-empty and printable")))
370    } else {
371        Ok(())
372    }
373}
374
375fn validate_timestamp(value: &str) -> Result<(), LiveCaptureError> {
376    validate_name("capturedAt", value)?;
377    // RFC-3339 requires a `T` separator and either `Z`/`z` or a `+HH:MM`/
378    // `-HH:MM` offset — a `-` inside the date part must not count, so only
379    // the portion after `T` is inspected for the offset sign.
380    let Some(time) = value.split_once('T').map(|(_, time)| time) else {
381        return Err(invalid(
382            "capturedAt must use an ISO-8601 timestamp with a timezone",
383        ));
384    };
385    let has_offset = time.ends_with('Z') || time.ends_with('z') || time.contains(['+', '-']);
386    if !has_offset {
387        return Err(invalid(
388            "capturedAt must use an ISO-8601 timestamp with a timezone",
389        ));
390    }
391    Ok(())
392}
393
394fn validate_catalog(catalog: &CatalogIdentity) -> Result<(), LiveCaptureError> {
395    validate_name(
396        "catalog.implementationVersion",
397        &catalog.implementation_version,
398    )?;
399    validate_name("catalog.catalogVersion", &catalog.catalog_version)?;
400    let digest = catalog
401        .catalog_digest
402        .as_deref()
403        .ok_or_else(|| invalid("catalog.catalogDigest is required to identify the catalog"))?;
404    validate_sha256("catalog.catalogDigest", digest)?;
405    validate_name("catalog.target.game", &catalog.target.game)?;
406    validate_name("catalog.target.format", &catalog.target.format)?;
407    validate_name("catalog.target.surface", &catalog.target.surface)?;
408    if catalog.locale_coverage.is_empty() {
409        return Err(invalid("catalog.localeCoverage must not be empty"));
410    }
411    Ok(())
412}
413
414fn validate_census(census: &CensusIdentity) -> Result<(), LiveCaptureError> {
415    if census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
416        return Err(invalid(format!(
417            "unsupported census identity schema version {}; expected {}",
418            census.schema_version, CENSUS_IDENTITY_SCHEMA_VERSION
419        )));
420    }
421    validate_sha256("census.digest", &census.digest)?;
422    if census.shards.is_empty() || census.shards.windows(2).any(|pair| pair[0] >= pair[1]) {
423        return Err(invalid(
424            "census.shards must be non-empty and strictly sorted",
425        ));
426    }
427    Ok(())
428}
429
430fn validate_artifact(
431    field: &str,
432    artifact: &TestArtifact,
433    require_pin: bool,
434) -> Result<(), LiveCaptureError> {
435    validate_name(&format!("{field}.name"), &artifact.name)?;
436    if require_pin {
437        validate_name(
438            &format!("{field}.revision"),
439            artifact.revision.as_deref().unwrap_or_default(),
440        )?;
441        validate_name(
442            &format!("{field}.path"),
443            artifact.path.as_deref().unwrap_or_default(),
444        )?;
445        validate_sha256(
446            &format!("{field}.sha256"),
447            artifact.sha256.as_deref().unwrap_or_default(),
448        )?;
449    }
450    Ok(())
451}
452
453fn validate_sha256(field: &str, digest: &str) -> Result<(), LiveCaptureError> {
454    if !is_sha256_digest(digest) {
455        return Err(invalid(format!(
456            "{field} must be a 64-character hexadecimal SHA-256 digest"
457        )));
458    }
459    Ok(())
460}
461
462fn normalize_game(value: &str) -> String {
463    value
464        .chars()
465        .filter(char::is_ascii_alphanumeric)
466        .flat_map(char::to_lowercase)
467        .collect()
468}
469
470fn same_features(left: &ConformanceResult, right: &ConformanceResult) -> bool {
471    left.features.len() == right.features.len()
472        && left
473            .features
474            .iter()
475            .all(|feature| right.features.contains(feature))
476}
477
478fn is_runtime_uncertain(result: &ConformanceResult) -> bool {
479    !result.status.is_match() || result.comparison.mode == Equivalence::NotComparable
480}
481
482#[cfg(test)]
483mod tests {
484    use super::super::conformance::{
485        CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, FeatureKind, FeatureNamespace,
486        ReasonCode,
487    };
488    use super::*;
489    use workshop_rs::catalog::Catalog;
490
491    const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
492    const OTHER_DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
493
494    fn catalog() -> CatalogIdentity {
495        Catalog::builtin()
496            .expect("built-in catalog for constructed unit data")
497            .identity()
498    }
499
500    fn raw(digest: &str) -> TestArtifact {
501        TestArtifact {
502            name: "constructed-unit-test/raw.ws".to_string(),
503            revision: Some("unit-test-revision".to_string()),
504            path: Some("constructed-unit-test/raw.ws".to_string()),
505            sha256: Some(digest.to_string()),
506            license: Some("MIT".to_string()),
507        }
508    }
509
510    fn feature(name: &str) -> FeatureId {
511        FeatureId::owned(FeatureNamespace::Wir, FeatureKind::Structural, name)
512            .expect("constructed feature identity")
513    }
514
515    fn result(
516        identity: &CatalogIdentity,
517        locale: &Locale,
518        raw: &TestArtifact,
519        case_id: &str,
520        status: ConformanceStatus,
521        feature_name: &str,
522    ) -> ConformanceResult {
523        let matched = status == ConformanceStatus::Matched;
524        ConformanceResult {
525            schema_version: CONFORMANCE_SCHEMA_VERSION,
526            case_id: case_id.to_string(),
527            features: vec![feature(feature_name)],
528            status,
529            comparison: Comparison {
530                mode: if matched {
531                    Equivalence::Normalized
532                } else {
533                    Equivalence::NotComparable
534                },
535                expected: matched.then(|| TestArtifact::new("constructed-unit-test/oracle")),
536                observed: matched.then(|| TestArtifact {
537                    name: "constructed-unit-test/observed.ws".to_string(),
538                    revision: Some("unit-test-revision".to_string()),
539                    path: Some("constructed-unit-test/raw.ws".to_string()),
540                    sha256: Some(raw.sha256.clone().unwrap()),
541                    license: Some("MIT".to_string()),
542                }),
543                normalizer: matched.then(|| "constructed-unit-test-normalizer".to_string()),
544            },
545            source: raw.clone(),
546            catalog: identity.clone(),
547            locale: Some(locale.clone()),
548            reason: (!matched).then(|| ConformanceReason {
549                code: ReasonCode::Inconclusive,
550                detail: "constructed unit uncertainty".to_string(),
551            }),
552        }
553    }
554
555    fn make_capture(
556        capture_id: &str,
557        locale: &str,
558        digest: &str,
559        result_status: ConformanceStatus,
560        feature_name: &str,
561    ) -> LiveCapture {
562        let identity = catalog();
563        let locale = Locale::new(locale);
564        let raw = raw(digest);
565        LiveCapture {
566            schema_version: LIVE_CAPTURE_SCHEMA_VERSION,
567            capture_id: capture_id.to_string(),
568            game: "overwatch-2".to_string(),
569            client: "constructed-unit-test-client".to_string(),
570            season: "constructed-unit-test-season".to_string(),
571            captured_at: "2026-08-18T00:00:00Z".to_string(),
572            environment: "constructed schema/diff unit test; not a client capture".to_string(),
573            locale: locale.clone(),
574            catalog: identity.clone(),
575            census: CensusIdentity {
576                schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
577                digest: DIGEST.to_string(),
578                shards: vec!["constructed-unit-test-shard".to_string()],
579            },
580            raw_artifact: raw.clone(),
581            results: vec![result(
582                &identity,
583                &locale,
584                &raw,
585                "constructed-unit-test/case",
586                result_status,
587                feature_name,
588            )],
589        }
590    }
591
592    #[test]
593    fn constructed_capture_schema_round_trips_without_runtime_claim() {
594        let capture = make_capture(
595            "capture-a",
596            "en-US",
597            DIGEST,
598            ConformanceStatus::Matched,
599            "one",
600        );
601        let json = capture.to_json().expect("constructed schema serializes");
602        let decoded = LiveCapture::from_json(&json).expect("constructed schema validates");
603        assert_eq!(decoded, capture);
604    }
605
606    #[test]
607    fn schema_rejects_missing_raw_pin_and_mismatched_capture_metadata() {
608        let mut capture = make_capture(
609            "capture-a",
610            "en-US",
611            DIGEST,
612            ConformanceStatus::Matched,
613            "one",
614        );
615        capture.raw_artifact.sha256 = None;
616        assert!(capture.validate().is_err());
617
618        let mut capture = make_capture(
619            "capture-a",
620            "en-US",
621            DIGEST,
622            ConformanceStatus::Matched,
623            "one",
624        );
625        capture.game = "not-overwatch".to_string();
626        assert!(capture.validate().is_err());
627
628        let mut capture = make_capture(
629            "capture-a",
630            "en-US",
631            DIGEST,
632            ConformanceStatus::Matched,
633            "one",
634        );
635        capture.results[0].source.name = "different source".to_string();
636        assert!(capture.validate().is_err());
637    }
638
639    #[test]
640    fn diff_reports_requested_categories_and_separates_runtime_uncertainty() {
641        let prior = make_capture(
642            "capture-a",
643            "en-US",
644            DIGEST,
645            ConformanceStatus::Matched,
646            "one",
647        );
648        let mut newer = make_capture(
649            "capture-b",
650            "zh-CN",
651            OTHER_DIGEST,
652            ConformanceStatus::Inconclusive,
653            "two",
654        );
655        newer.catalog.catalog_digest = Some(OTHER_DIGEST.to_string());
656        newer.results[0].catalog = newer.catalog.clone();
657        let diff = prior.diff(&newer).expect("constructed diff validates");
658        let categories: HashSet<_> = diff.changes.iter().map(|entry| entry.category).collect();
659        assert!(categories.contains(&DiffCategory::Locale));
660        assert!(categories.contains(&DiffCategory::Catalog));
661        assert!(categories.contains(&DiffCategory::Content));
662        assert!(categories.contains(&DiffCategory::SemanticSchema));
663        // A `Matched` baseline that drifts to any other status is a real
664        // change, not a runtime uncertainty.
665        assert!(
666            diff.changes
667                .iter()
668                .any(|entry| entry.detail.contains("conformance status changed")),
669            "status drift from a comparable baseline must be a change: {:?}",
670            diff.changes
671        );
672        assert!(
673            diff.runtime_uncertainty
674                .iter()
675                .all(|entry| !entry.detail.contains("comparable semantic match")),
676            "a certain baseline reports drift as a change, not uncertainty"
677        );
678        let json = diff.to_json().expect("structured diff serializes");
679        let document: serde_json::Value = serde_json::from_str(&json).expect("valid diff JSON");
680        assert!(document["changes"].is_array());
681        assert!(document["runtimeUncertainty"].is_array());
682    }
683
684    /// C2 companion: drift *from* an uncertain baseline is unverifiable and
685    /// lands in `runtime_uncertainty`, not `changes`.
686    #[test]
687    fn diff_reports_uncertainty_when_baseline_not_comparable() {
688        let prior = make_capture(
689            "capture-a",
690            "en-US",
691            DIGEST,
692            ConformanceStatus::Inconclusive,
693            "one",
694        );
695        let newer = make_capture(
696            "capture-b",
697            "en-US",
698            DIGEST,
699            ConformanceStatus::Matched,
700            "one",
701        );
702        let diff = prior.diff(&newer).expect("constructed diff validates");
703        assert!(
704            diff.runtime_uncertainty
705                .iter()
706                .any(|entry| entry.category == DiffCategory::RuntimeUncertainty),
707            "an uncertain baseline cannot attribute drift to a real change"
708        );
709        assert!(
710            !diff
711                .changes
712                .iter()
713                .any(|entry| entry.detail.contains("conformance status changed")),
714            "status drift on an uncertain baseline is not a change: {:?}",
715            diff.changes
716        );
717    }
718
719    /// C3: RFC-3339 offsets other than `Z`/`+` must validate, and a `+`
720    /// anywhere outside the time part must not count as an offset.
721    #[test]
722    fn timestamp_accepts_rfc3339_offsets() {
723        for valid in [
724            "2026-08-18T00:00:00Z",
725            "2026-08-18T00:00:00z",
726            "2026-08-18T00:00:00-08:00",
727            "2026-08-18T00:00:00+08:00",
728        ] {
729            validate_timestamp(valid).unwrap_or_else(|_| panic!("{valid} must validate"));
730        }
731        for invalid in ["2026-08-18", "2026-08-18T00:00:00", "not+a+timestamp"] {
732            assert!(validate_timestamp(invalid).is_err(), "{invalid} must fail");
733        }
734    }
735
736    #[test]
737    fn diff_refuses_capture_with_invalid_result_metadata() {
738        let prior = make_capture(
739            "capture-a",
740            "en-US",
741            DIGEST,
742            ConformanceStatus::Matched,
743            "one",
744        );
745        let mut newer = make_capture(
746            "capture-b",
747            "en-US",
748            DIGEST,
749            ConformanceStatus::Matched,
750            "one",
751        );
752        newer.results[0].catalog.catalog_digest = Some("different".to_string());
753        assert!(prior.diff(&newer).is_err());
754    }
755}