reconciliation_reducer.rs (27225B)
1 //! Pure deterministic reduction of one sealed reconciliation manifest. 2 3 use core::fmt; 4 use std::{collections::BTreeSet, error::Error}; 5 6 use radroots_event::{ 7 id::{MutationId, TradeId}, 8 trade::trade_mutation_from_canonical_content, 9 }; 10 use radroots_trade::{ 11 evidence::{ 12 RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceOutcomeV1, 13 RadrootsTradeEvidenceStateV1, RadrootsTradeMutationRecordV1, 14 }, 15 model::{RadrootsTradeAgreementStateV1, RadrootsTradeProjectionV1}, 16 reducer::{ 17 RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, 18 RadrootsTradeReductionInputV1, reduce_trade_records, 19 }, 20 }; 21 use sha2::{Digest, Sha256}; 22 23 use crate::{ 24 RhiReconciliationManifest, 25 reconciliation_manifest::{ 26 RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, RHI_REDUCER_MAXIMUM_MUTATIONS, 27 RhiReducerMutationMaterial, 28 }, 29 }; 30 31 /// Exact version of the RHI reconciliation-reducer binding. 32 pub const RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 = 1; 33 /// Exact version of the RHI reconciliation coverage/outcome binding. 34 pub const RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION: u32 = 1; 35 36 /// Exact shared four-state reconciliation coverage vocabulary. 37 pub type RhiReconciliationCoverage = RadrootsTradeEvidenceCoverageV1; 38 /// Exact shared three-state reconciliation outcome vocabulary. 39 pub type RhiReconciliationOutcome = RadrootsTradeEvidenceOutcomeV1; 40 41 const PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.reconciliation_projection.v1\0"; 42 43 /// Stable source-free reconciliation-reducer failure class. 44 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 45 pub enum RhiReconciliationReducerErrorKind { 46 InvalidManifest, 47 ProjectionUnavailable, 48 } 49 50 impl RhiReconciliationReducerErrorKind { 51 /// Returns the stable machine-readable failure code. 52 #[must_use] 53 pub const fn code(self) -> &'static str { 54 match self { 55 Self::InvalidManifest => "reconciliation_reducer_manifest_invalid", 56 Self::ProjectionUnavailable => "reconciliation_reducer_projection_unavailable", 57 } 58 } 59 } 60 61 /// Redacted source-free reconciliation-reducer failure. 62 #[derive(Clone, Copy, PartialEq, Eq)] 63 pub struct RhiReconciliationReducerError { 64 kind: RhiReconciliationReducerErrorKind, 65 } 66 67 impl RhiReconciliationReducerError { 68 /// Returns the stable failure class. 69 #[must_use] 70 pub const fn kind(self) -> RhiReconciliationReducerErrorKind { 71 self.kind 72 } 73 74 /// Returns the stable machine-readable failure code. 75 #[must_use] 76 pub const fn code(self) -> &'static str { 77 self.kind.code() 78 } 79 } 80 81 impl fmt::Display for RhiReconciliationReducerError { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 formatter.write_str(match self.kind { 84 RhiReconciliationReducerErrorKind::InvalidManifest => { 85 "RHI reconciliation manifest cannot be reduced" 86 } 87 RhiReconciliationReducerErrorKind::ProjectionUnavailable => { 88 "RHI reconciliation projection is unavailable" 89 } 90 }) 91 } 92 } 93 94 impl fmt::Debug for RhiReconciliationReducerError { 95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 96 formatter 97 .debug_struct("RhiReconciliationReducerError") 98 .field("kind", &self.kind) 99 .finish() 100 } 101 } 102 103 impl Error for RhiReconciliationReducerError {} 104 105 /// Sealed deterministic projection retaining its exact manifest capability. 106 /// 107 /// Callers cannot construct or relabel a projection. 108 /// 109 /// ```compile_fail 110 /// use rhi::RhiReconciliationProjection; 111 /// 112 /// let _forged = RhiReconciliationProjection {}; 113 /// ``` 114 pub struct RhiReconciliationProjection { 115 manifest: RhiReconciliationManifest, 116 shared: RadrootsTradeProjectionV1, 117 shared_projection_digest: Option<[u8; 32]>, 118 digest: Option<[u8; 32]>, 119 } 120 121 impl RhiReconciliationProjection { 122 /// Returns the exact RHI reducer-binding contract version. 123 #[must_use] 124 pub const fn contract_version(&self) -> u32 { 125 RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION 126 } 127 128 /// Returns the exact shared reducer contract ID. 129 #[must_use] 130 pub const fn shared_reducer_contract_id(&self) -> &'static str { 131 RADROOTS_TRADE_REDUCER_CONTRACT_ID 132 } 133 134 /// Returns the exact shared reducer contract version. 135 #[must_use] 136 pub const fn shared_reducer_contract_version(&self) -> u16 { 137 RADROOTS_TRADE_REDUCER_VERSION 138 } 139 140 /// Returns the sealed manifest consumed by this projection. 141 #[must_use] 142 pub const fn manifest(&self) -> &RhiReconciliationManifest { 143 &self.manifest 144 } 145 146 /// Returns the exact trade selected by the immutable manifest. 147 #[must_use] 148 pub const fn trade_id(&self) -> &TradeId { 149 self.manifest.trade_id() 150 } 151 152 /// Returns the exact shared projection digest decoded from lowercase hex. 153 #[must_use] 154 pub const fn shared_projection_digest(&self) -> Option<[u8; 32]> { 155 self.shared_projection_digest 156 } 157 158 /// Returns the domain-separated RHI projection digest. 159 #[must_use] 160 pub const fn digest(&self) -> Option<[u8; 32]> { 161 self.digest 162 } 163 164 /// Returns the number of canonical shared reducer issues. 165 #[must_use] 166 pub fn issue_count(&self) -> usize { 167 self.shared.issues().len() 168 } 169 170 /// Returns the selected root mutation when the manifest establishes one. 171 #[must_use] 172 pub const fn root_mutation_id(&self) -> Option<&MutationId> { 173 self.shared.root_mutation_id() 174 } 175 } 176 177 /// Stable closed reason for one claim-specific reconciliation outcome. 178 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] 179 pub enum RhiReconciliationReasonCode { 180 RequiredEvidenceMissing, 181 RequiredSourceIncomplete, 182 RequiredSourceUnsupported, 183 ProjectionDigestUnavailable, 184 GoverningSchemaUnsupported, 185 ReducerIssueUnresolved, 186 AgreementClaimMissing, 187 AgreementClaimUnresolved, 188 ScopeSatisfied, 189 AgreementClaimCancelled, 190 } 191 192 impl RhiReconciliationReasonCode { 193 /// Returns the exact stable lowercase report reason code. 194 #[must_use] 195 pub const fn code(self) -> &'static str { 196 match self { 197 Self::RequiredEvidenceMissing => "required_evidence_missing", 198 Self::RequiredSourceIncomplete => "required_source_incomplete", 199 Self::RequiredSourceUnsupported => "required_source_unsupported", 200 Self::ProjectionDigestUnavailable => "projection_digest_unavailable", 201 Self::GoverningSchemaUnsupported => "governing_schema_unsupported", 202 Self::ReducerIssueUnresolved => "reducer_issue_unresolved", 203 Self::AgreementClaimMissing => "agreement_claim_missing", 204 Self::AgreementClaimUnresolved => "agreement_claim_unresolved", 205 Self::ScopeSatisfied => "scope_satisfied", 206 Self::AgreementClaimCancelled => "agreement_claim_cancelled", 207 } 208 } 209 } 210 211 /// Sealed claim-specific coverage and outcome derived from one projection. 212 /// 213 /// Callers cannot construct or relabel an evaluation. 214 /// 215 /// ```compile_fail 216 /// use rhi::RhiReconciliationEvaluation; 217 /// 218 /// let _forged = RhiReconciliationEvaluation {}; 219 /// ``` 220 pub struct RhiReconciliationEvaluation { 221 projection: RhiReconciliationProjection, 222 claim_mutation_id: MutationId, 223 coverage: RhiReconciliationCoverage, 224 outcome: RhiReconciliationOutcome, 225 reason_codes: [RhiReconciliationReasonCode; 1], 226 } 227 228 impl RhiReconciliationEvaluation { 229 /// Returns the exact coverage/outcome contract version. 230 #[must_use] 231 pub const fn contract_version(&self) -> u32 { 232 RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION 233 } 234 235 /// Returns the sealed projection evaluated for this claim. 236 #[must_use] 237 pub const fn projection(&self) -> &RhiReconciliationProjection { 238 &self.projection 239 } 240 241 /// Returns the exact typed claim selected by the evaluation. 242 #[must_use] 243 pub const fn claim_mutation_id(&self) -> &MutationId { 244 &self.claim_mutation_id 245 } 246 247 /// Returns the exact four-state evidence coverage. 248 #[must_use] 249 pub const fn coverage(&self) -> RhiReconciliationCoverage { 250 self.coverage 251 } 252 253 /// Returns the exact three-state claim outcome. 254 #[must_use] 255 pub const fn outcome(&self) -> RhiReconciliationOutcome { 256 self.outcome 257 } 258 259 /// Returns the exact bounded stable reason-code inventory. 260 #[must_use] 261 pub const fn reason_codes(&self) -> &[RhiReconciliationReasonCode] { 262 &self.reason_codes 263 } 264 } 265 266 impl fmt::Debug for RhiReconciliationEvaluation { 267 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 268 formatter 269 .debug_struct("RhiReconciliationEvaluation") 270 .field("coverage", &self.coverage) 271 .field("outcome", &self.outcome) 272 .field("reason_codes", &self.reason_codes) 273 .finish_non_exhaustive() 274 } 275 } 276 277 impl fmt::Debug for RhiReconciliationProjection { 278 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 279 formatter 280 .debug_struct("RhiReconciliationProjection") 281 .field("source_count", &self.manifest.source_count()) 282 .field("observation_count", &self.manifest.observation_count()) 283 .field("issue_count", &self.issue_count()) 284 .finish_non_exhaustive() 285 } 286 } 287 288 /// Reduces one sealed immutable reconciliation manifest without external I/O. 289 pub fn reduce_rhi_reconciliation_manifest( 290 manifest: RhiReconciliationManifest, 291 ) -> Result<RhiReconciliationProjection, RhiReconciliationReducerError> { 292 let inner = manifest.inner(); 293 if !mutation_material_within_bounds(manifest.reducer_mutations()) { 294 return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); 295 } 296 let observation_inventory = inner 297 .observations() 298 .iter() 299 .map(|observation| (*observation.mutation_id(), *observation.event_id())) 300 .collect::<BTreeSet<_>>(); 301 if inner.observations().iter().any(|observation| { 302 manifest 303 .reducer_mutations() 304 .binary_search_by_key(observation.mutation_id(), |material| material.mutation_id) 305 .is_err() 306 }) { 307 return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); 308 } 309 310 let mut mutations = Vec::with_capacity(manifest.reducer_mutations().len()); 311 for material in manifest.reducer_mutations() { 312 if !observation_inventory.contains(&(material.mutation_id, material.event_id)) { 313 return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); 314 } 315 let content = core::str::from_utf8(&material.canonical_content) 316 .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?; 317 let mutation = trade_mutation_from_canonical_content(content) 318 .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?; 319 if mutation.mutation_id != Some(material.mutation_id) 320 || mutation.trade_id != *inner.trade_id() 321 { 322 return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); 323 } 324 mutations.push(RadrootsTradeMutationRecordV1::new( 325 Some(material.event_id), 326 mutation, 327 )); 328 } 329 330 let input = RadrootsTradeReductionInputV1::new(*inner.trade_id()) 331 .with_mutations(mutations) 332 .with_evidence_state(evidence_state(inner.coverage())) 333 .with_observed_at_unix_s(Some(inner.observed_at_unix_s())); 334 let shared = reduce_trade_records(input); 335 if shared.reducer_contract_id() != RADROOTS_TRADE_REDUCER_CONTRACT_ID 336 || shared.reducer_version() != RADROOTS_TRADE_REDUCER_VERSION 337 || shared.trade_id() != inner.trade_id() 338 { 339 return Err(failure( 340 RhiReconciliationReducerErrorKind::ProjectionUnavailable, 341 )); 342 } 343 let shared_projection_digest = decode_lower_hex_32(shared.projection_digest()); 344 let digest = shared_projection_digest.and_then(|shared_projection_digest| { 345 projection_digest(&manifest, shared_projection_digest) 346 }); 347 Ok(RhiReconciliationProjection { 348 manifest, 349 shared, 350 shared_projection_digest, 351 digest, 352 }) 353 } 354 355 /// Evaluates one exact typed agreement claim against a sealed projection. 356 pub fn evaluate_rhi_reconciliation_claim( 357 projection: RhiReconciliationProjection, 358 claim_mutation_id: MutationId, 359 ) -> RhiReconciliationEvaluation { 360 let shared = &projection.shared; 361 let facts = EvaluationFacts { 362 coverage: projection.manifest.inner().coverage(), 363 shared_evidence: shared.evidence_state(), 364 projection_digest_available: projection.digest.is_some(), 365 reducer_issue_present: !shared.issues().is_empty(), 366 claim_present: shared 367 .agreement_claims() 368 .iter() 369 .any(|claim| claim.claim_mutation_id() == &claim_mutation_id), 370 claim_active: shared 371 .active_agreement_claim_ids() 372 .contains(&claim_mutation_id), 373 claim_contested: shared.contested_claim_ids().contains(&claim_mutation_id), 374 claim_cancelled: shared.cancelled_claim_ids().contains(&claim_mutation_id), 375 agreement_agreed: shared.agreement_state() == RadrootsTradeAgreementStateV1::Agreed, 376 }; 377 let (outcome, reason) = classify_evaluation(facts); 378 RhiReconciliationEvaluation { 379 projection, 380 claim_mutation_id, 381 coverage: facts.coverage, 382 outcome, 383 reason_codes: [reason], 384 } 385 } 386 387 #[derive(Clone, Copy)] 388 struct EvaluationFacts { 389 coverage: RhiReconciliationCoverage, 390 shared_evidence: RadrootsTradeEvidenceStateV1, 391 projection_digest_available: bool, 392 reducer_issue_present: bool, 393 claim_present: bool, 394 claim_active: bool, 395 claim_contested: bool, 396 claim_cancelled: bool, 397 agreement_agreed: bool, 398 } 399 400 fn classify_evaluation( 401 facts: EvaluationFacts, 402 ) -> (RhiReconciliationOutcome, RhiReconciliationReasonCode) { 403 use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid}; 404 use RhiReconciliationReasonCode::{ 405 AgreementClaimCancelled, AgreementClaimMissing, AgreementClaimUnresolved, 406 GoverningSchemaUnsupported, ProjectionDigestUnavailable, ReducerIssueUnresolved, 407 RequiredEvidenceMissing, RequiredSourceIncomplete, RequiredSourceUnsupported, 408 ScopeSatisfied, 409 }; 410 411 match facts.coverage { 412 RhiReconciliationCoverage::Missing => return (Indeterminate, RequiredEvidenceMissing), 413 RhiReconciliationCoverage::Partial => return (Indeterminate, RequiredSourceIncomplete), 414 RhiReconciliationCoverage::Unsupported => { 415 return (Indeterminate, RequiredSourceUnsupported); 416 } 417 RhiReconciliationCoverage::ScopeSatisfied => {} 418 } 419 if !facts.projection_digest_available { 420 return (Indeterminate, ProjectionDigestUnavailable); 421 } 422 match facts.shared_evidence { 423 RadrootsTradeEvidenceStateV1::Missing => { 424 return (Indeterminate, RequiredEvidenceMissing); 425 } 426 RadrootsTradeEvidenceStateV1::QueryPartial => { 427 return (Indeterminate, RequiredSourceIncomplete); 428 } 429 RadrootsTradeEvidenceStateV1::UnsupportedVersion => { 430 return (Indeterminate, GoverningSchemaUnsupported); 431 } 432 RadrootsTradeEvidenceStateV1::Complete => {} 433 } 434 if facts.reducer_issue_present 435 || facts.claim_contested 436 || (facts.claim_active && facts.claim_cancelled) 437 { 438 return (Indeterminate, ReducerIssueUnresolved); 439 } 440 if !facts.claim_present { 441 return (Indeterminate, AgreementClaimMissing); 442 } 443 if facts.claim_active && facts.agreement_agreed { 444 return (Valid, ScopeSatisfied); 445 } 446 if facts.claim_cancelled && !facts.claim_active { 447 return (Invalid, AgreementClaimCancelled); 448 } 449 (Indeterminate, AgreementClaimUnresolved) 450 } 451 452 fn mutation_material_within_bounds(materials: &[RhiReducerMutationMaterial]) -> bool { 453 material_lengths_within_bounds( 454 materials.len(), 455 materials 456 .iter() 457 .map(|material| material.canonical_content.len()), 458 ) 459 } 460 461 fn material_lengths_within_bounds<I>(count: usize, lengths: I) -> bool 462 where 463 I: IntoIterator<Item = usize>, 464 { 465 count <= RHI_REDUCER_MAXIMUM_MUTATIONS 466 && lengths 467 .into_iter() 468 .try_fold(0_usize, usize::checked_add) 469 .is_some_and(|total| total <= RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES) 470 } 471 472 fn evidence_state(coverage: RadrootsTradeEvidenceCoverageV1) -> RadrootsTradeEvidenceStateV1 { 473 match coverage { 474 RadrootsTradeEvidenceCoverageV1::Missing => RadrootsTradeEvidenceStateV1::Missing, 475 RadrootsTradeEvidenceCoverageV1::Partial => RadrootsTradeEvidenceStateV1::QueryPartial, 476 RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => RadrootsTradeEvidenceStateV1::Complete, 477 RadrootsTradeEvidenceCoverageV1::Unsupported => { 478 RadrootsTradeEvidenceStateV1::UnsupportedVersion 479 } 480 } 481 } 482 483 fn projection_digest(manifest: &RhiReconciliationManifest, shared: [u8; 32]) -> Option<[u8; 32]> { 484 let inner = manifest.inner(); 485 let mut digest = Sha256::new(); 486 digest.update(PROJECTION_DIGEST_DOMAIN); 487 digest.update(RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION.to_be_bytes()); 488 digest.update( 489 u64::try_from(RADROOTS_TRADE_REDUCER_CONTRACT_ID.len()) 490 .ok()? 491 .to_be_bytes(), 492 ); 493 digest.update(RADROOTS_TRADE_REDUCER_CONTRACT_ID.as_bytes()); 494 digest.update(RADROOTS_TRADE_REDUCER_VERSION.to_be_bytes()); 495 digest.update(manifest.digest()); 496 digest.update(inner.evidence_policy_digest().as_bytes()); 497 digest.update(shared); 498 Some(digest.finalize().into()) 499 } 500 501 fn decode_lower_hex_32(value: &str) -> Option<[u8; 32]> { 502 if value.len() != 64 { 503 return None; 504 } 505 let mut output = [0_u8; 32]; 506 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { 507 output[index] = decode_lower_hex(pair[0])? 508 .checked_mul(16)? 509 .checked_add(decode_lower_hex(pair[1])?)?; 510 } 511 Some(output) 512 } 513 514 const fn decode_lower_hex(value: u8) -> Option<u8> { 515 match value { 516 b'0'..=b'9' => Some(value - b'0'), 517 b'a'..=b'f' => Some(value - b'a' + 10), 518 _ => None, 519 } 520 } 521 522 const fn failure(kind: RhiReconciliationReducerErrorKind) -> RhiReconciliationReducerError { 523 RhiReconciliationReducerError { kind } 524 } 525 526 #[cfg(test)] 527 mod tests { 528 use super::*; 529 530 fn scope_satisfied_facts() -> EvaluationFacts { 531 EvaluationFacts { 532 coverage: RhiReconciliationCoverage::ScopeSatisfied, 533 shared_evidence: RadrootsTradeEvidenceStateV1::Complete, 534 projection_digest_available: true, 535 reducer_issue_present: false, 536 claim_present: true, 537 claim_active: true, 538 claim_contested: false, 539 claim_cancelled: false, 540 agreement_agreed: true, 541 } 542 } 543 544 #[test] 545 fn diagnostics_and_digest_decoder_are_closed() { 546 for kind in [ 547 RhiReconciliationReducerErrorKind::InvalidManifest, 548 RhiReconciliationReducerErrorKind::ProjectionUnavailable, 549 ] { 550 let error = failure(kind); 551 assert_eq!(error.kind(), kind); 552 assert!(Error::source(&error).is_none()); 553 assert!(!format!("{error} {error:?}").contains("trade-primary")); 554 } 555 assert_eq!(decode_lower_hex_32(&"ab".repeat(32)), Some([0xab; 32])); 556 assert_eq!(decode_lower_hex_32(&"AB".repeat(32)), None); 557 assert_eq!(decode_lower_hex_32("00"), None); 558 } 559 560 #[test] 561 fn mutation_material_length_and_count_bounds_are_exact() { 562 assert!(material_lengths_within_bounds( 563 RHI_REDUCER_MAXIMUM_MUTATIONS, 564 [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES] 565 )); 566 assert!(!material_lengths_within_bounds( 567 RHI_REDUCER_MAXIMUM_MUTATIONS, 568 [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, 1] 569 )); 570 assert!(!material_lengths_within_bounds( 571 RHI_REDUCER_MAXIMUM_MUTATIONS + 1, 572 core::iter::empty() 573 )); 574 assert!(!material_lengths_within_bounds(1, [usize::MAX, 1])); 575 } 576 577 #[test] 578 fn coverage_and_claim_state_matrix_is_total_and_fail_closed() { 579 use RadrootsTradeEvidenceCoverageV1::{Missing, Partial, ScopeSatisfied, Unsupported}; 580 use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid}; 581 use RhiReconciliationReasonCode::{ 582 AgreementClaimCancelled, AgreementClaimMissing, RequiredEvidenceMissing, 583 RequiredSourceIncomplete, RequiredSourceUnsupported, ScopeSatisfied as ScopeReason, 584 }; 585 586 for (coverage, incomplete_reason) in [ 587 (Missing, RequiredEvidenceMissing), 588 (Partial, RequiredSourceIncomplete), 589 (Unsupported, RequiredSourceUnsupported), 590 ] { 591 for (present, active, cancelled) in [ 592 (false, false, false), 593 (true, true, false), 594 (true, false, true), 595 ] { 596 let actual = classify_evaluation(EvaluationFacts { 597 coverage, 598 claim_present: present, 599 claim_active: active, 600 claim_cancelled: cancelled, 601 ..scope_satisfied_facts() 602 }); 603 assert_eq!(actual, (Indeterminate, incomplete_reason)); 604 assert!(coverage.permits(actual.0)); 605 } 606 } 607 608 for (present, active, cancelled, expected) in [ 609 (false, false, false, (Indeterminate, AgreementClaimMissing)), 610 (true, true, false, (Valid, ScopeReason)), 611 (true, false, true, (Invalid, AgreementClaimCancelled)), 612 ] { 613 let actual = classify_evaluation(EvaluationFacts { 614 coverage: ScopeSatisfied, 615 claim_present: present, 616 claim_active: active, 617 claim_cancelled: cancelled, 618 ..scope_satisfied_facts() 619 }); 620 assert_eq!(actual, expected); 621 assert!(ScopeSatisfied.permits(actual.0)); 622 } 623 } 624 625 #[test] 626 fn fail_closed_precedence_covers_every_unavailable_or_ambiguous_fact() { 627 use RadrootsTradeEvidenceOutcomeV1::Indeterminate; 628 use RhiReconciliationReasonCode::{ 629 AgreementClaimUnresolved, GoverningSchemaUnsupported, ProjectionDigestUnavailable, 630 ReducerIssueUnresolved, RequiredEvidenceMissing, RequiredSourceIncomplete, 631 }; 632 633 let cases = [ 634 ( 635 EvaluationFacts { 636 projection_digest_available: false, 637 ..scope_satisfied_facts() 638 }, 639 ProjectionDigestUnavailable, 640 ), 641 ( 642 EvaluationFacts { 643 shared_evidence: RadrootsTradeEvidenceStateV1::Missing, 644 ..scope_satisfied_facts() 645 }, 646 RequiredEvidenceMissing, 647 ), 648 ( 649 EvaluationFacts { 650 shared_evidence: RadrootsTradeEvidenceStateV1::QueryPartial, 651 ..scope_satisfied_facts() 652 }, 653 RequiredSourceIncomplete, 654 ), 655 ( 656 EvaluationFacts { 657 shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion, 658 ..scope_satisfied_facts() 659 }, 660 GoverningSchemaUnsupported, 661 ), 662 ( 663 EvaluationFacts { 664 reducer_issue_present: true, 665 ..scope_satisfied_facts() 666 }, 667 ReducerIssueUnresolved, 668 ), 669 ( 670 EvaluationFacts { 671 claim_contested: true, 672 ..scope_satisfied_facts() 673 }, 674 ReducerIssueUnresolved, 675 ), 676 ( 677 EvaluationFacts { 678 claim_cancelled: true, 679 ..scope_satisfied_facts() 680 }, 681 ReducerIssueUnresolved, 682 ), 683 ( 684 EvaluationFacts { 685 agreement_agreed: false, 686 ..scope_satisfied_facts() 687 }, 688 AgreementClaimUnresolved, 689 ), 690 ( 691 EvaluationFacts { 692 claim_active: false, 693 claim_cancelled: false, 694 ..scope_satisfied_facts() 695 }, 696 AgreementClaimUnresolved, 697 ), 698 ]; 699 for (facts, reason) in cases { 700 assert_eq!(classify_evaluation(facts), (Indeterminate, reason)); 701 } 702 703 let precedence = classify_evaluation(EvaluationFacts { 704 coverage: RhiReconciliationCoverage::Missing, 705 projection_digest_available: false, 706 shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion, 707 reducer_issue_present: true, 708 claim_present: false, 709 ..scope_satisfied_facts() 710 }); 711 assert_eq!(precedence, (Indeterminate, RequiredEvidenceMissing)); 712 } 713 714 #[test] 715 fn reason_codes_are_closed_stable_and_source_free() { 716 let inventory = [ 717 ( 718 RhiReconciliationReasonCode::RequiredEvidenceMissing, 719 "required_evidence_missing", 720 ), 721 ( 722 RhiReconciliationReasonCode::RequiredSourceIncomplete, 723 "required_source_incomplete", 724 ), 725 ( 726 RhiReconciliationReasonCode::RequiredSourceUnsupported, 727 "required_source_unsupported", 728 ), 729 ( 730 RhiReconciliationReasonCode::ProjectionDigestUnavailable, 731 "projection_digest_unavailable", 732 ), 733 ( 734 RhiReconciliationReasonCode::GoverningSchemaUnsupported, 735 "governing_schema_unsupported", 736 ), 737 ( 738 RhiReconciliationReasonCode::ReducerIssueUnresolved, 739 "reducer_issue_unresolved", 740 ), 741 ( 742 RhiReconciliationReasonCode::AgreementClaimMissing, 743 "agreement_claim_missing", 744 ), 745 ( 746 RhiReconciliationReasonCode::AgreementClaimUnresolved, 747 "agreement_claim_unresolved", 748 ), 749 ( 750 RhiReconciliationReasonCode::ScopeSatisfied, 751 "scope_satisfied", 752 ), 753 ( 754 RhiReconciliationReasonCode::AgreementClaimCancelled, 755 "agreement_claim_cancelled", 756 ), 757 ]; 758 assert_eq!(inventory.len(), 10); 759 for (reason, code) in inventory { 760 assert_eq!(reason.code(), code); 761 assert!(!code.contains("source://")); 762 } 763 } 764 }