Skip to main content

workshop_rs/
live_capture.rs

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