Skip to main content

traverse_runtime/events/
ecca_conformance.rs

1//! ECCA catalog-drift reconciliation and migration-exit conformance.
2//!
3//! Closes the remaining #897 slices against `traverse-registry` 0.11.0:
4//! declared/observed drift via [`ObservedLineageStore`], portable descriptor
5//! fixture conformance, and Spec 534 FR-015 migration-exit evidence.
6
7use crate::events::{
8    EventLineageRecord, EventQuarantineRecord, EventTelemetryRecord, EventValidationEvidence,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::fs;
13use std::path::Path;
14use traverse_registry::{
15    DriftEvidence, EventProductDescriptor, EventProductErrorCode, EventProductRegistry,
16    LookupScope, ObservedEventInteraction, ObservedLineageStore, ObservedRole, RegistryScope,
17    validate_event_product_descriptor,
18};
19
20/// Reconciles broker lineage against a declared [`EventProductRegistry`].
21#[derive(Debug, Clone, Default)]
22pub struct CatalogDriftReconciler {
23    registry: EventProductRegistry,
24    observed: ObservedLineageStore,
25    observed_keys: BTreeSet<(String, String)>,
26}
27
28impl CatalogDriftReconciler {
29    #[must_use]
30    pub fn new(registry: EventProductRegistry) -> Self {
31        Self {
32            registry,
33            observed: ObservedLineageStore::new(),
34            observed_keys: BTreeSet::new(),
35        }
36    }
37
38    #[must_use]
39    pub fn registry(&self) -> &EventProductRegistry {
40        &self.registry
41    }
42
43    #[must_use]
44    pub fn observed(&self) -> &ObservedLineageStore {
45        &self.observed
46    }
47
48    /// Records one publish observation against declared publishers for the event.
49    pub fn observe_publication(
50        &mut self,
51        event_id: &str,
52        event_version: &str,
53        capability_id: &str,
54        observed_at: &str,
55    ) {
56        self.observed_keys
57            .insert((event_id.to_string(), event_version.to_string()));
58        let declared = declared_capability_ids(
59            &self.registry,
60            event_id,
61            event_version,
62            ObservedRole::Publisher,
63        );
64        self.observed.record(
65            ObservedEventInteraction {
66                event_id: event_id.to_string(),
67                event_version: event_version.to_string(),
68                capability_id: capability_id.to_string(),
69                role: ObservedRole::Publisher,
70                observed_at: observed_at.to_string(),
71            },
72            &declared,
73        );
74    }
75
76    /// Records one consume observation against declared subscribers for the event.
77    pub fn observe_consumption(
78        &mut self,
79        event_id: &str,
80        event_version: &str,
81        capability_id: &str,
82        observed_at: &str,
83    ) {
84        self.observed_keys
85            .insert((event_id.to_string(), event_version.to_string()));
86        let declared = declared_capability_ids(
87            &self.registry,
88            event_id,
89            event_version,
90            ObservedRole::Subscriber,
91        );
92        self.observed.record(
93            ObservedEventInteraction {
94                event_id: event_id.to_string(),
95                event_version: event_version.to_string(),
96                capability_id: capability_id.to_string(),
97                role: ObservedRole::Subscriber,
98                observed_at: observed_at.to_string(),
99            },
100            &declared,
101        );
102    }
103
104    /// Projects sanitized broker lineage into the registry observed-lineage store.
105    pub fn reconcile_broker_lineage(&mut self, lineage: &[EventLineageRecord], observed_at: &str) {
106        for record in lineage {
107            self.observe_publication(
108                &record.contract_id,
109                &record.contract_version,
110                &record.producer_id,
111                observed_at,
112            );
113            self.observe_consumption(
114                &record.contract_id,
115                &record.contract_version,
116                &record.consumer_id,
117                observed_at,
118            );
119        }
120    }
121
122    /// All unresolved drift evidence recorded so far, in deterministic key order.
123    #[must_use]
124    pub fn unresolved_drift(&self) -> Vec<DriftEvidence> {
125        let mut keys = self.observed_keys.clone();
126        for descriptor in self.registry.discover(LookupScope::PreferPrivate) {
127            keys.insert((
128                descriptor.contract.id.clone(),
129                descriptor.contract.version.clone(),
130            ));
131        }
132        keys.into_iter()
133            .flat_map(|(event_id, event_version)| {
134                self.observed
135                    .drift_for(&event_id, &event_version)
136                    .into_iter()
137                    .cloned()
138            })
139            .collect()
140    }
141}
142
143fn declared_capability_ids(
144    registry: &EventProductRegistry,
145    event_id: &str,
146    event_version: &str,
147    role: ObservedRole,
148) -> Vec<String> {
149    let Some(descriptor) = find_descriptor(registry, event_id, event_version) else {
150        return Vec::new();
151    };
152    match role {
153        ObservedRole::Publisher => descriptor
154            .contract
155            .publishers
156            .iter()
157            .map(|reference| reference.capability_id.clone())
158            .collect(),
159        ObservedRole::Subscriber => descriptor
160            .contract
161            .subscribers
162            .iter()
163            .map(|reference| reference.capability_id.clone())
164            .collect(),
165    }
166}
167
168fn find_descriptor<'a>(
169    registry: &'a EventProductRegistry,
170    event_id: &str,
171    event_version: &str,
172) -> Option<&'a EventProductDescriptor> {
173    registry
174        .find_exact(RegistryScope::Private, event_id, event_version)
175        .or_else(|| registry.find_exact(RegistryScope::Public, event_id, event_version))
176}
177
178/// Stable finding codes for FR-015 migration-exit evaluation.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum MigrationExitFindingCode {
182    MissingProducerTelemetry,
183    MissingConsumerTelemetry,
184    UnresolvedDrift,
185    InvalidEventFinding,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct MigrationExitFinding {
190    pub code: MigrationExitFindingCode,
191    pub detail: String,
192}
193
194/// Persisted evidence for one release conformance run (FR-015 consecutive runs).
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct MigrationExitEvidence {
197    pub run_id: String,
198    pub clean: bool,
199    pub finding_count: usize,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct MigrationExitReport {
204    pub permitted: bool,
205    pub findings: Vec<MigrationExitFinding>,
206    pub evidence: MigrationExitEvidence,
207    pub previous_run_clean: bool,
208}
209
210/// Evaluate Spec 534 FR-015 cutover readiness for one conformance run.
211#[must_use]
212pub fn evaluate_migration_exit(
213    registry: &EventProductRegistry,
214    reconciler: &CatalogDriftReconciler,
215    validation_evidence: &[EventValidationEvidence],
216    quarantine: &[EventQuarantineRecord],
217    telemetry: &[EventTelemetryRecord],
218    previous: Option<&MigrationExitEvidence>,
219    run_id: &str,
220) -> MigrationExitReport {
221    let mut findings = Vec::new();
222
223    // Registered descriptors already passed `validate_event_product_descriptor`
224    // at register time; FR-015 "every published contract validates" is therefore
225    // satisfied by registry membership plus the portable fixture suite.
226    for descriptor in registry.discover(LookupScope::PreferPrivate) {
227        let contract_id = descriptor.contract.id.as_str();
228        let contract_version = descriptor.contract.version.as_str();
229        let has_publish_telemetry = telemetry.iter().any(|record| {
230            record.operation == "traverse.event.publish"
231                && record.contract_id == contract_id
232                && record.contract_version == contract_version
233        });
234        if !descriptor.contract.publishers.is_empty() && !has_publish_telemetry {
235            findings.push(MigrationExitFinding {
236                code: MigrationExitFindingCode::MissingProducerTelemetry,
237                detail: format!(
238                    "declared producers for {contract_id}@{contract_version} have no publish telemetry"
239                ),
240            });
241        }
242
243        for subscriber in &descriptor.contract.subscribers {
244            let has_delivery = telemetry.iter().any(|record| {
245                record.operation == "traverse.event.delivery"
246                    && record.contract_id == contract_id
247                    && record.contract_version == contract_version
248                    && record.consumer_id.as_deref() == Some(subscriber.capability_id.as_str())
249            });
250            if !has_delivery {
251                findings.push(MigrationExitFinding {
252                    code: MigrationExitFindingCode::MissingConsumerTelemetry,
253                    detail: format!(
254                        "declared consumer '{}' for {contract_id}@{contract_version} has no delivery telemetry",
255                        subscriber.capability_id
256                    ),
257                });
258            }
259        }
260    }
261
262    for evidence in reconciler.unresolved_drift() {
263        findings.push(MigrationExitFinding {
264            code: MigrationExitFindingCode::UnresolvedDrift,
265            detail: format!(
266                "{:?} capability '{}' on {}@{}",
267                evidence.kind, evidence.capability_id, evidence.event_id, evidence.event_version
268            ),
269        });
270    }
271
272    for evidence in validation_evidence {
273        findings.push(MigrationExitFinding {
274            code: MigrationExitFindingCode::InvalidEventFinding,
275            detail: format!(
276                "validation diagnostic for {}@{} ({})",
277                evidence.contract_id,
278                evidence.version,
279                evidence
280                    .diagnostics
281                    .first()
282                    .map_or("unknown", |diagnostic| diagnostic.code)
283            ),
284        });
285    }
286    for record in quarantine {
287        findings.push(MigrationExitFinding {
288            code: MigrationExitFindingCode::InvalidEventFinding,
289            detail: format!(
290                "quarantine recorded for {}@{}",
291                record.evidence.contract_id, record.evidence.version
292            ),
293        });
294    }
295
296    let clean = findings.is_empty();
297    let finding_count = findings.len();
298    let previous_run_clean = previous.is_some_and(|evidence| evidence.clean);
299    let permitted = clean && previous_run_clean;
300
301    MigrationExitReport {
302        permitted,
303        findings,
304        evidence: MigrationExitEvidence {
305            run_id: run_id.to_string(),
306            clean,
307            finding_count,
308        },
309        previous_run_clean,
310    }
311}
312
313/// One portable descriptor-fixture expectation from the registry corpus.
314#[derive(Debug, Clone, Deserialize)]
315struct FixtureManifest {
316    fixtures: Vec<FixtureEntry>,
317}
318
319#[derive(Debug, Clone, Deserialize)]
320struct FixtureEntry {
321    file: String,
322    expect: String,
323    #[serde(default)]
324    error_code: Option<String>,
325    #[serde(default)]
326    existing: Option<String>,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct FixtureConformanceFailure {
331    pub file: String,
332    pub detail: String,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct FixtureConformanceReport {
337    pub passed: usize,
338    pub failures: Vec<FixtureConformanceFailure>,
339}
340
341/// Run the portable ECCA descriptor fixture corpus against
342/// [`validate_event_product_descriptor`].
343///
344/// # Errors
345///
346/// Returns an error when the manifest or fixture files cannot be read/parsed.
347pub fn run_descriptor_fixture_conformance(
348    fixtures_dir: &Path,
349) -> Result<FixtureConformanceReport, String> {
350    let manifest_path = fixtures_dir.join("MANIFEST.json");
351    let manifest_text = fs::read_to_string(&manifest_path)
352        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
353    let manifest: FixtureManifest = serde_json::from_str(&manifest_text)
354        .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
355
356    let mut passed = 0;
357    let mut failures = Vec::new();
358    let mut loaded: BTreeMap<String, EventProductDescriptor> = BTreeMap::new();
359
360    for entry in &manifest.fixtures {
361        let path = fixtures_dir.join(&entry.file);
362        let text = match fs::read_to_string(&path) {
363            Ok(text) => text,
364            Err(error) => {
365                failures.push(FixtureConformanceFailure {
366                    file: entry.file.clone(),
367                    detail: format!("failed to read fixture: {error}"),
368                });
369                continue;
370            }
371        };
372        let descriptor: EventProductDescriptor = match serde_json::from_str(&text) {
373            Ok(descriptor) => descriptor,
374            Err(error) => {
375                failures.push(FixtureConformanceFailure {
376                    file: entry.file.clone(),
377                    detail: format!("failed to parse fixture JSON: {error}"),
378                });
379                continue;
380            }
381        };
382
383        let existing = entry
384            .existing
385            .as_ref()
386            .and_then(|existing_file| loaded.get(existing_file));
387        let result = validate_event_product_descriptor(&descriptor, existing);
388        match (entry.expect.as_str(), result) {
389            ("accept", Ok(())) => {
390                passed += 1;
391                loaded.insert(entry.file.clone(), descriptor);
392            }
393            ("accept", Err(failure)) => failures.push(FixtureConformanceFailure {
394                file: entry.file.clone(),
395                detail: format!(
396                    "expected accept, got {:?}",
397                    failure
398                        .errors
399                        .first()
400                        .map_or(EventProductErrorCode::MissingSupportRoute, |error| error
401                            .code)
402                ),
403            }),
404            ("reject", Ok(())) => failures.push(FixtureConformanceFailure {
405                file: entry.file.clone(),
406                detail: "expected reject, got accept".to_string(),
407            }),
408            ("reject", Err(failure)) => {
409                let actual = failure
410                    .errors
411                    .first()
412                    .map(|error| format!("{:?}", error.code));
413                if entry.error_code.as_ref() == actual.as_ref() {
414                    passed += 1;
415                } else {
416                    failures.push(FixtureConformanceFailure {
417                        file: entry.file.clone(),
418                        detail: format!(
419                            "expected error_code {:?}, got {:?}",
420                            entry.error_code, actual
421                        ),
422                    });
423                }
424            }
425            (other, _) => failures.push(FixtureConformanceFailure {
426                file: entry.file.clone(),
427                detail: format!("unsupported expect value '{other}'"),
428            }),
429        }
430    }
431
432    Ok(FixtureConformanceReport { passed, failures })
433}
434
435/// Validate one event-product descriptor JSON document.
436///
437/// # Errors
438///
439/// Returns a stable string error when the file is unreadable, unparsable, or
440/// fails ECCA descriptor validation.
441pub fn validate_event_product_file(path: &Path) -> Result<EventProductDescriptor, String> {
442    let text = fs::read_to_string(path)
443        .map_err(|error| format!("failed to read event product descriptor: {error}"))?;
444    let descriptor: EventProductDescriptor = serde_json::from_str(&text)
445        .map_err(|error| format!("failed to parse event product descriptor: {error}"))?;
446    validate_event_product_descriptor(&descriptor, None).map_err(|failure| {
447        failure
448            .errors
449            .into_iter()
450            .map(|error| format!("{:?}: {}", error.code, error.message))
451            .collect::<Vec<_>>()
452            .join("; ")
453    })?;
454    Ok(descriptor)
455}
456
457#[cfg(test)]
458#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
459mod tests {
460    use super::*;
461    use crate::events::validation::EventValidationDiagnostic;
462    use traverse_contracts::EventContract;
463    use traverse_registry::{
464        DataClassification, DriftKind, EventExposureClass, EventProductRegistration,
465        FieldClassification,
466    };
467
468    fn sample_descriptor(
469        event_id: &str,
470        publisher: &str,
471        subscriber: Option<&str>,
472    ) -> EventProductDescriptor {
473        let mut contract: EventContract = serde_json::from_str(
474            r#"{
475              "kind":"event_contract",
476              "schema_version":"1.0.0",
477              "id":"placeholder",
478              "namespace":"content.comments",
479              "name":"comment-draft-created",
480              "version":"1.0.0",
481              "lifecycle":"active",
482              "owner":{"team":"traverse-core","contact":"test@example.com"},
483              "summary":"Published when a comment draft has been created.",
484              "description":"Governed event contract for comment draft creation.",
485              "payload":{"schema":{"type":"object","properties":{"draft_id":{"type":"string"}},"required":["draft_id"]},"compatibility":"backward-compatible"},
486              "classification":{"domain":"content.comments","bounded_context":"comments","event_type":"domain","tags":[]},
487              "publishers":[{"capability_id":"content.comments.create-comment-draft","version":"1.0.0"}],
488              "subscribers":[],
489              "policies":[],
490              "tags":[],
491              "provenance":{"source":"greenfield","author":"test","created_at":"2026-03-30T00:00:00Z"},
492              "evidence":[]
493            }"#,
494        )
495        .expect("contract json");
496        contract.id = event_id.to_string();
497        contract.name = event_id.rsplit('.').next().unwrap_or(event_id).to_string();
498        contract.publishers[0].capability_id = publisher.to_string();
499        if let Some(subscriber) = subscriber {
500            contract.subscribers = vec![traverse_contracts::CapabilityReference {
501                capability_id: subscriber.to_string(),
502                version: "1.0.0".to_string(),
503            }];
504        }
505        EventProductDescriptor {
506            contract,
507            support_route: "https://support.traverse.dev/comments".to_string(),
508            exposure: EventExposureClass::Internal,
509            field_classifications: vec![FieldClassification {
510                field_path: "draft_id".to_string(),
511                classification: DataClassification::NoClassification,
512            }],
513            replacement: None,
514            cloud_events_source: format!("traverse://capability/{publisher}"),
515            cloud_events_subject_field: Some("draft_id".to_string()),
516            deduplication_id_field: "draft_id".to_string(),
517            ordering_scope_field: None,
518            correlation_id_field: "envelope.correlation_id".to_string(),
519            causation_id_field: Some("envelope.causation_id".to_string()),
520            retention_policy: "retain 90 days".to_string(),
521        }
522    }
523
524    fn registry_with(descriptor: EventProductDescriptor) -> EventProductRegistry {
525        let mut registry = EventProductRegistry::new();
526        registry
527            .register(EventProductRegistration {
528                scope: RegistryScope::Private,
529                descriptor,
530            })
531            .expect("descriptor must register");
532        registry
533    }
534
535    #[test]
536    fn declared_producer_and_consumer_produce_no_drift() {
537        let descriptor = sample_descriptor(
538            "content.comments.comment-draft-created",
539            "content.comments.create-comment-draft",
540            Some("content.comments.notify-author"),
541        );
542        let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
543        reconciler.observe_publication(
544            "content.comments.comment-draft-created",
545            "1.0.0",
546            "content.comments.create-comment-draft",
547            "2026-08-07T00:00:00Z",
548        );
549        reconciler.observe_consumption(
550            "content.comments.comment-draft-created",
551            "1.0.0",
552            "content.comments.notify-author",
553            "2026-08-07T00:00:01Z",
554        );
555        assert!(reconciler.unresolved_drift().is_empty());
556    }
557
558    #[test]
559    fn undeclared_producer_is_reported_as_drift() {
560        let descriptor = sample_descriptor(
561            "content.comments.comment-draft-created",
562            "content.comments.create-comment-draft",
563            None,
564        );
565        let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
566        reconciler.observe_publication(
567            "content.comments.comment-draft-created",
568            "1.0.0",
569            "rogue.publisher",
570            "2026-08-07T00:00:00Z",
571        );
572        let drift = reconciler.unresolved_drift();
573        assert_eq!(drift.len(), 1);
574        assert_eq!(drift[0].kind, DriftKind::UndeclaredPublisher);
575        assert_eq!(drift[0].capability_id, "rogue.publisher");
576    }
577
578    #[test]
579    fn broker_lineage_projects_into_observed_store() {
580        let descriptor = sample_descriptor(
581            "content.comments.comment-draft-created",
582            "content.comments.create-comment-draft",
583            Some("content.comments.notify-author"),
584        );
585        let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
586        reconciler.reconcile_broker_lineage(
587            &[EventLineageRecord {
588                contract_id: "content.comments.comment-draft-created".to_string(),
589                contract_version: "1.0.0".to_string(),
590                event_id: "evt-1".to_string(),
591                producer_id: "content.comments.create-comment-draft".to_string(),
592                consumer_id: "content.comments.notify-author".to_string(),
593                subscription_id: "sub-1".to_string(),
594                cursor: "1".to_string(),
595            }],
596            "2026-08-07T00:00:00Z",
597        );
598        assert!(reconciler.unresolved_drift().is_empty());
599        assert_eq!(
600            reconciler
601                .observed()
602                .interactions_for("content.comments.comment-draft-created", "1.0.0")
603                .len(),
604            2
605        );
606    }
607
608    #[test]
609    fn migration_exit_requires_two_consecutive_clean_runs() {
610        let descriptor = sample_descriptor(
611            "content.comments.comment-draft-created",
612            "content.comments.create-comment-draft",
613            Some("content.comments.notify-author"),
614        );
615        let registry = registry_with(descriptor);
616        let mut reconciler = CatalogDriftReconciler::new(registry.clone());
617        reconciler.observe_publication(
618            "content.comments.comment-draft-created",
619            "1.0.0",
620            "content.comments.create-comment-draft",
621            "2026-08-07T00:00:00Z",
622        );
623        reconciler.observe_consumption(
624            "content.comments.comment-draft-created",
625            "1.0.0",
626            "content.comments.notify-author",
627            "2026-08-07T00:00:01Z",
628        );
629        let telemetry = vec![
630            EventTelemetryRecord {
631                operation: "traverse.event.publish",
632                outcome: "accepted",
633                contract_id: "content.comments.comment-draft-created".to_string(),
634                contract_version: "1.0.0".to_string(),
635                event_id: "evt-1".to_string(),
636                deduplication_id: None,
637                ordering_scope: None,
638                correlation_id: None,
639                causation_id: None,
640                consumer_id: None,
641                cursor: None,
642                retry_count: 0,
643                latency_ms: 0,
644            },
645            EventTelemetryRecord {
646                operation: "traverse.event.delivery",
647                outcome: "delivered",
648                contract_id: "content.comments.comment-draft-created".to_string(),
649                contract_version: "1.0.0".to_string(),
650                event_id: "evt-1".to_string(),
651                deduplication_id: None,
652                ordering_scope: None,
653                correlation_id: None,
654                causation_id: None,
655                consumer_id: Some("content.comments.notify-author".to_string()),
656                cursor: Some("1".to_string()),
657                retry_count: 0,
658                latency_ms: 0,
659            },
660        ];
661
662        let first =
663            evaluate_migration_exit(&registry, &reconciler, &[], &[], &telemetry, None, "run-1");
664        assert!(first.evidence.clean, "findings: {:?}", first.findings);
665        assert!(!first.permitted);
666
667        let second = evaluate_migration_exit(
668            &registry,
669            &reconciler,
670            &[],
671            &[],
672            &telemetry,
673            Some(&first.evidence),
674            "run-2",
675        );
676        assert!(second.evidence.clean);
677        assert!(second.permitted);
678        assert!(second.previous_run_clean);
679    }
680
681    #[test]
682    fn migration_exit_blocks_on_invalid_event_findings() {
683        let descriptor = sample_descriptor(
684            "content.comments.comment-draft-created",
685            "content.comments.create-comment-draft",
686            None,
687        );
688        let registry = registry_with(descriptor);
689        let reconciler = CatalogDriftReconciler::new(registry.clone());
690        let evidence = EventValidationEvidence {
691            contract_id: "content.comments.comment-draft-created".to_string(),
692            version: "1.0.0".to_string(),
693            diagnostics: vec![EventValidationDiagnostic {
694                code: "EVP-001",
695                path: "/id",
696                severity: "error",
697                remediation: "provide id",
698                contract_id: "content.comments.comment-draft-created".to_string(),
699                version: "1.0.0".to_string(),
700            }],
701        };
702        let report = evaluate_migration_exit(
703            &registry,
704            &reconciler,
705            &[evidence],
706            &[],
707            &[],
708            None,
709            "run-bad",
710        );
711        assert!(!report.evidence.clean);
712        assert!(
713            report
714                .findings
715                .iter()
716                .any(|finding| finding.code == MigrationExitFindingCode::InvalidEventFinding)
717        );
718    }
719
720    #[test]
721    fn validate_event_product_file_accepts_valid_fixture() {
722        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
723            .join("tests/fixtures/ecca-event-products/valid.json");
724        let descriptor = validate_event_product_file(&path).expect("valid fixture");
725        assert_eq!(
726            descriptor.contract.id,
727            "content.comments.comment-draft-created"
728        );
729    }
730
731    #[test]
732    fn registry_accessor_and_unknown_event_observation_are_covered() {
733        let descriptor = sample_descriptor(
734            "content.comments.comment-draft-created",
735            "content.comments.create-comment-draft",
736            None,
737        );
738        let mut reconciler = CatalogDriftReconciler::new(registry_with(descriptor));
739        assert_eq!(
740            reconciler
741                .registry()
742                .discover(LookupScope::PreferPrivate)
743                .len(),
744            1
745        );
746        reconciler.observe_publication(
747            "unknown.event",
748            "1.0.0",
749            "any.capability",
750            "2026-08-07T00:00:00Z",
751        );
752        assert_eq!(reconciler.unresolved_drift().len(), 1);
753    }
754
755    #[test]
756    fn public_scope_descriptors_resolve_for_declared_capabilities() {
757        let descriptor = sample_descriptor(
758            "content.comments.comment-draft-created",
759            "content.comments.create-comment-draft",
760            None,
761        );
762        let mut registry = EventProductRegistry::new();
763        registry
764            .register(EventProductRegistration {
765                scope: RegistryScope::Public,
766                descriptor,
767            })
768            .expect("public descriptor must register");
769        let mut reconciler = CatalogDriftReconciler::new(registry);
770        reconciler.observe_publication(
771            "content.comments.comment-draft-created",
772            "1.0.0",
773            "content.comments.create-comment-draft",
774            "2026-08-07T00:00:00Z",
775        );
776        assert!(reconciler.unresolved_drift().is_empty());
777    }
778
779    #[test]
780    fn migration_exit_reports_missing_consumer_telemetry_and_drift() {
781        let descriptor = sample_descriptor(
782            "content.comments.comment-draft-created",
783            "content.comments.create-comment-draft",
784            Some("content.comments.notify-author"),
785        );
786        let registry = registry_with(descriptor);
787        let mut reconciler = CatalogDriftReconciler::new(registry.clone());
788        reconciler.observe_publication(
789            "content.comments.comment-draft-created",
790            "1.0.0",
791            "rogue.publisher",
792            "2026-08-07T00:00:00Z",
793        );
794        let report =
795            evaluate_migration_exit(&registry, &reconciler, &[], &[], &[], None, "run-gaps");
796        assert!(
797            report
798                .findings
799                .iter()
800                .any(|finding| finding.code == MigrationExitFindingCode::MissingProducerTelemetry)
801        );
802        assert!(
803            report
804                .findings
805                .iter()
806                .any(|finding| finding.code == MigrationExitFindingCode::MissingConsumerTelemetry)
807        );
808        assert!(
809            report
810                .findings
811                .iter()
812                .any(|finding| finding.code == MigrationExitFindingCode::UnresolvedDrift)
813        );
814    }
815
816    #[test]
817    fn migration_exit_reports_quarantine_and_empty_diagnostic_codes() {
818        let descriptor = sample_descriptor(
819            "content.comments.comment-draft-created",
820            "content.comments.create-comment-draft",
821            None,
822        );
823        let registry = registry_with(descriptor);
824        let reconciler = CatalogDriftReconciler::new(registry.clone());
825        let evidence = EventValidationEvidence {
826            contract_id: "content.comments.comment-draft-created".to_string(),
827            version: "1.0.0".to_string(),
828            diagnostics: Vec::new(),
829        };
830        let quarantine = EventQuarantineRecord {
831            evidence: evidence.clone(),
832        };
833        let report = evaluate_migration_exit(
834            &registry,
835            &reconciler,
836            std::slice::from_ref(&evidence),
837            std::slice::from_ref(&quarantine),
838            &[],
839            None,
840            "run-quarantine",
841        );
842        assert_eq!(
843            report
844                .findings
845                .iter()
846                .filter(|finding| finding.code == MigrationExitFindingCode::InvalidEventFinding)
847                .count(),
848            2
849        );
850        assert!(
851            report
852                .findings
853                .iter()
854                .any(|finding| finding.detail.contains("unknown"))
855        );
856    }
857
858    #[test]
859    fn validate_event_product_file_rejects_invalid_fixture() {
860        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
861            .join("tests/fixtures/ecca-event-products/reject_missing_support_route.json");
862        let error = validate_event_product_file(&path).expect_err("invalid fixture");
863        assert!(error.contains("MissingSupportRoute"));
864    }
865
866    #[test]
867    fn fixture_conformance_reports_error_and_mismatch_branches() {
868        let root =
869            std::env::temp_dir().join(format!("ecca-fixture-branches-{}", std::process::id()));
870        let _ = fs::remove_dir_all(&root);
871        fs::create_dir_all(&root).expect("temp fixture dir");
872
873        let valid = fs::read_to_string(
874            Path::new(env!("CARGO_MANIFEST_DIR"))
875                .join("tests/fixtures/ecca-event-products/valid.json"),
876        )
877        .expect("valid fixture");
878        fs::write(root.join("valid.json"), &valid).expect("write valid");
879        fs::write(root.join("broken.json"), "{not-json").expect("write broken");
880        fs::write(
881            root.join("MANIFEST.json"),
882            r#"{
883              "fixtures": [
884                {"file":"valid.json","expect":"accept"},
885                {"file":"missing.json","expect":"accept"},
886                {"file":"broken.json","expect":"accept"},
887                {"file":"valid.json","expect":"reject","error_code":"MissingSupportRoute"},
888                {"file":"valid.json","expect":"reject","error_code":"WrongCode"},
889                {"file":"valid.json","expect":"weird"}
890              ]
891            }"#,
892        )
893        .expect("write manifest");
894
895        // Force accept-path rejection by pointing expect=accept at a reject fixture.
896        let reject = fs::read_to_string(
897            Path::new(env!("CARGO_MANIFEST_DIR"))
898                .join("tests/fixtures/ecca-event-products/reject_missing_support_route.json"),
899        )
900        .expect("reject fixture");
901        fs::write(root.join("invalid.json"), reject).expect("write invalid");
902        fs::write(
903            root.join("MANIFEST.json"),
904            r#"{
905              "fixtures": [
906                {"file":"valid.json","expect":"accept"},
907                {"file":"missing.json","expect":"accept"},
908                {"file":"broken.json","expect":"accept"},
909                {"file":"invalid.json","expect":"accept"},
910                {"file":"valid.json","expect":"reject","error_code":"MissingSupportRoute"},
911                {"file":"invalid.json","expect":"reject","error_code":"WrongCode"},
912                {"file":"valid.json","expect":"weird"}
913              ]
914            }"#,
915        )
916        .expect("rewrite manifest");
917
918        let report = run_descriptor_fixture_conformance(&root).expect("runner must return");
919        assert!(!report.failures.is_empty());
920        assert!(
921            report
922                .failures
923                .iter()
924                .any(|failure| failure.detail.contains("failed to read fixture"))
925        );
926        assert!(
927            report
928                .failures
929                .iter()
930                .any(|failure| failure.detail.contains("failed to parse fixture JSON"))
931        );
932        assert!(
933            report
934                .failures
935                .iter()
936                .any(|failure| failure.detail.contains("expected accept, got"))
937        );
938        assert!(
939            report
940                .failures
941                .iter()
942                .any(|failure| failure.detail.contains("expected reject, got accept"))
943        );
944        assert!(
945            report
946                .failures
947                .iter()
948                .any(|failure| failure.detail.contains("expected error_code"))
949        );
950        assert!(
951            report
952                .failures
953                .iter()
954                .any(|failure| failure.detail.contains("unsupported expect value"))
955        );
956        let _ = fs::remove_dir_all(&root);
957    }
958
959    #[test]
960    fn fixture_conformance_rejects_unreadable_manifest() {
961        let root = std::env::temp_dir().join(format!(
962            "ecca-fixture-missing-manifest-{}",
963            std::process::id()
964        ));
965        let _ = fs::remove_dir_all(&root);
966        fs::create_dir_all(&root).expect("temp dir");
967        let error = run_descriptor_fixture_conformance(&root).expect_err("missing manifest");
968        assert!(error.contains("failed to read"));
969        let _ = fs::remove_dir_all(&root);
970    }
971
972    #[test]
973    fn fixture_conformance_rejects_unparsable_manifest() {
974        let root =
975            std::env::temp_dir().join(format!("ecca-fixture-bad-manifest-{}", std::process::id()));
976        let _ = fs::remove_dir_all(&root);
977        fs::create_dir_all(&root).expect("temp dir");
978        fs::write(root.join("MANIFEST.json"), "{not-json").expect("write");
979        let error = run_descriptor_fixture_conformance(&root).expect_err("bad manifest");
980        assert!(error.contains("failed to parse"));
981        let _ = fs::remove_dir_all(&root);
982    }
983
984    #[test]
985    fn validate_event_product_file_reports_io_and_parse_errors() {
986        let missing = std::env::temp_dir().join(format!(
987            "ecca-missing-descriptor-{}.json",
988            std::process::id()
989        ));
990        let _ = fs::remove_file(&missing);
991        let io_error = validate_event_product_file(&missing).expect_err("missing file");
992        assert!(io_error.contains("failed to read"));
993
994        let broken = std::env::temp_dir().join(format!(
995            "ecca-broken-descriptor-{}.json",
996            std::process::id()
997        ));
998        fs::write(&broken, "{not-json").expect("write broken");
999        let parse_error = validate_event_product_file(&broken).expect_err("bad json");
1000        assert!(parse_error.contains("failed to parse"));
1001        let _ = fs::remove_file(&broken);
1002    }
1003}