1use std::collections::HashSet;
7
8use cortex_core::{
9 compose_policy_outcomes, CoreError, CoreResult, PolicyContribution, PolicyDecision,
10 PolicyOutcome,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ResolutionState {
16 Resolved,
18 MultiHypothesis,
20 Unknown,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum AuthorityLevel {
27 Low,
29 Medium,
31 High,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ProofClosureHint {
38 FullChainVerified,
40 Partial,
42 Unknown,
44 Broken {
46 edge: String,
48 },
49}
50
51impl ProofClosureHint {
52 fn is_full_chain_verified(&self) -> bool {
53 matches!(self, Self::FullChainVerified)
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct AuthorityProofHint {
60 pub authority: AuthorityLevel,
62 pub proof: ProofClosureHint,
64}
65
66impl AuthorityProofHint {
67 #[must_use]
69 pub fn is_high_authority_verified(&self) -> bool {
70 self.authority == AuthorityLevel::High && self.proof.is_full_chain_verified()
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ConflictingMemoryInput {
77 pub memory_id: String,
79 pub claim_key: Option<String>,
81 pub claim: String,
83 pub authority: AuthorityProofHint,
85 pub conflicts_with: Vec<String>,
87}
88
89impl ConflictingMemoryInput {
90 #[must_use]
92 pub fn new(
93 memory_id: impl Into<String>,
94 claim_key: Option<impl Into<String>>,
95 claim: impl Into<String>,
96 authority: AuthorityProofHint,
97 ) -> Self {
98 Self {
99 memory_id: memory_id.into(),
100 claim_key: claim_key.map(Into::into),
101 claim: claim.into(),
102 authority,
103 conflicts_with: Vec::new(),
104 }
105 }
106
107 #[must_use]
109 pub fn with_conflicts(mut self, conflicts_with: Vec<String>) -> Self {
110 self.conflicts_with = conflicts_with;
111 self
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct PrecedenceEvidence {
118 pub winner_memory_id: String,
120 pub loser_memory_ids: Vec<String>,
122 pub reason: String,
124 pub proof: ProofClosureHint,
126}
127
128#[allow(missing_docs)]
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum ResolutionReason {
132 NoInputs,
134 SingleCandidate,
136 DuplicateClaim,
138 ExplicitPrecedence { winner_memory_id: String },
140 ConflictingClaims,
142 MissingClaimKey { memory_id: String },
144 PartialProof { memory_id: String },
146 UnknownProof { memory_id: String },
148 BrokenProof { memory_id: String, edge: String },
150 HighAuthorityVerifiedConflictRequiresPrecedence,
152 IncompletePrecedence {
154 winner_memory_id: String,
155 missing_loser_ids: Vec<String>,
156 },
157 AmbiguousPrecedence { winner_memory_ids: Vec<String> },
159}
160
161impl ResolutionReason {
162 const fn policy_rule_id(&self) -> &'static str {
163 match self {
164 Self::NoInputs => "retrieval.resolve.no_inputs",
165 Self::SingleCandidate => "retrieval.resolve.single_candidate",
166 Self::DuplicateClaim => "retrieval.resolve.duplicate_claim",
167 Self::ExplicitPrecedence { .. } => "retrieval.resolve.explicit_precedence",
168 Self::ConflictingClaims => "retrieval.resolve.conflicting_claims",
169 Self::MissingClaimKey { .. } => "retrieval.resolve.missing_claim_key",
170 Self::PartialProof { .. } => "retrieval.resolve.partial_proof",
171 Self::UnknownProof { .. } => "retrieval.resolve.unknown_proof",
172 Self::BrokenProof { .. } => "retrieval.resolve.broken_proof",
173 Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
174 "retrieval.resolve.high_authority_conflict_requires_precedence"
175 }
176 Self::IncompletePrecedence { .. } => "retrieval.resolve.incomplete_precedence",
177 Self::AmbiguousPrecedence { .. } => "retrieval.resolve.ambiguous_precedence",
178 }
179 }
180
181 const fn policy_outcome(&self, state: ResolutionState) -> PolicyOutcome {
182 match self {
183 Self::SingleCandidate | Self::DuplicateClaim | Self::ExplicitPrecedence { .. }
184 if matches!(state, ResolutionState::Resolved) =>
185 {
186 PolicyOutcome::Allow
187 }
188 Self::BrokenProof { .. } => PolicyOutcome::Reject,
189 _ => PolicyOutcome::Quarantine,
190 }
191 }
192
193 const fn policy_reason(&self) -> &'static str {
194 match self {
195 Self::NoInputs => "retrieval conflict resolver received no inputs",
196 Self::SingleCandidate => "single candidate can be consumed",
197 Self::DuplicateClaim => "duplicate claims resolved by deterministic authority ordering",
198 Self::ExplicitPrecedence { .. } => "explicit full-chain precedence resolved conflict",
199 Self::ConflictingClaims => "conflicting claims require multi-hypothesis handling",
200 Self::MissingClaimKey { .. } => "candidate is missing claim-key evidence",
201 Self::PartialProof { .. } => "candidate proof is partial",
202 Self::UnknownProof { .. } => "candidate proof is unknown",
203 Self::BrokenProof { .. } => "candidate proof is broken",
204 Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
205 "high-authority verified conflict requires explicit precedence"
206 }
207 Self::IncompletePrecedence { .. } => "precedence evidence does not cover all losers",
208 Self::AmbiguousPrecedence { .. } => "multiple precedence winners remain ambiguous",
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct ResolverOutput {
216 pub state: ResolutionState,
218 pub selected: Option<ConflictingMemoryInput>,
220 pub hypotheses: Vec<ConflictingMemoryInput>,
222 pub reasons: Vec<ResolutionReason>,
224}
225
226impl ResolverOutput {
227 #[must_use]
229 pub fn policy_decision(&self) -> PolicyDecision {
230 if self.reasons.is_empty() {
231 return compose_policy_outcomes(
232 vec![PolicyContribution::new(
233 "retrieval.resolve.no_reason",
234 PolicyOutcome::Quarantine,
235 "resolver returned no reason and cannot be treated as clean authority",
236 )
237 .expect("static policy contribution is valid")],
238 None,
239 );
240 }
241
242 let contributions = self
243 .reasons
244 .iter()
245 .map(|reason| {
246 PolicyContribution::new(
247 reason.policy_rule_id(),
248 reason.policy_outcome(self.state),
249 reason.policy_reason(),
250 )
251 .expect("static policy contribution is valid")
252 })
253 .collect();
254 compose_policy_outcomes(contributions, None)
255 }
256
257 pub fn require_default_use_allowed(&self) -> CoreResult<()> {
264 let policy = self.policy_decision();
265 match policy.final_outcome {
266 PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
267 Err(CoreError::Validation(format!(
268 "retrieval resolver default use blocked by policy outcome {:?}",
269 policy.final_outcome
270 )))
271 }
272 PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
273 }
274 }
275}
276
277#[must_use]
279pub fn resolve_conflicts(
280 inputs: &[ConflictingMemoryInput],
281 precedence: &[PrecedenceEvidence],
282) -> ResolverOutput {
283 let candidates = sorted_candidates(inputs);
284 if candidates.is_empty() {
285 return ResolverOutput {
286 state: ResolutionState::Unknown,
287 selected: None,
288 hypotheses: Vec::new(),
289 reasons: vec![ResolutionReason::NoInputs],
290 };
291 }
292
293 let mut reasons = proof_reasons(&candidates);
294 reasons.extend(missing_claim_key_reasons(&candidates));
295 if !reasons.is_empty() {
296 return ResolverOutput {
297 state: ResolutionState::Unknown,
298 selected: None,
299 hypotheses: candidates,
300 reasons,
301 };
302 }
303
304 if candidates.len() == 1 {
305 return ResolverOutput {
306 state: ResolutionState::Resolved,
307 selected: candidates.first().cloned(),
308 hypotheses: candidates,
309 reasons: vec![ResolutionReason::SingleCandidate],
310 };
311 }
312
313 if !has_conflict(&candidates) {
314 let selected = strongest_candidate(&candidates);
315 return ResolverOutput {
316 state: ResolutionState::Resolved,
317 selected: Some(selected),
318 hypotheses: candidates,
319 reasons: vec![ResolutionReason::DuplicateClaim],
320 };
321 }
322
323 match valid_precedence_winner(&candidates, precedence) {
324 PrecedenceMatch::One(winner) => ResolverOutput {
325 state: ResolutionState::Resolved,
326 selected: Some(winner.clone()),
327 hypotheses: vec![winner.clone()],
328 reasons: vec![ResolutionReason::ExplicitPrecedence {
329 winner_memory_id: winner.memory_id,
330 }],
331 },
332 PrecedenceMatch::Many(winner_memory_ids) => ResolverOutput {
333 state: ResolutionState::MultiHypothesis,
334 selected: None,
335 hypotheses: candidates,
336 reasons: vec![ResolutionReason::AmbiguousPrecedence { winner_memory_ids }],
337 },
338 PrecedenceMatch::Incomplete {
339 winner_memory_id,
340 missing_loser_ids,
341 } => ResolverOutput {
342 state: ResolutionState::MultiHypothesis,
343 selected: None,
344 hypotheses: candidates,
345 reasons: vec![ResolutionReason::IncompletePrecedence {
346 winner_memory_id,
347 missing_loser_ids,
348 }],
349 },
350 PrecedenceMatch::None => unresolved_conflict(candidates),
351 }
352}
353
354fn unresolved_conflict(candidates: Vec<ConflictingMemoryInput>) -> ResolverOutput {
355 let mut reasons = vec![ResolutionReason::ConflictingClaims];
356 if high_authority_verified_conflict(&candidates) {
357 reasons.push(ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence);
358 }
359
360 ResolverOutput {
361 state: ResolutionState::MultiHypothesis,
362 selected: None,
363 hypotheses: candidates,
364 reasons,
365 }
366}
367
368fn proof_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
369 let mut reasons = Vec::new();
370 for candidate in candidates {
371 match &candidate.authority.proof {
372 ProofClosureHint::FullChainVerified => {}
373 ProofClosureHint::Partial => reasons.push(ResolutionReason::PartialProof {
374 memory_id: candidate.memory_id.clone(),
375 }),
376 ProofClosureHint::Unknown => reasons.push(ResolutionReason::UnknownProof {
377 memory_id: candidate.memory_id.clone(),
378 }),
379 ProofClosureHint::Broken { edge } => reasons.push(ResolutionReason::BrokenProof {
380 memory_id: candidate.memory_id.clone(),
381 edge: edge.clone(),
382 }),
383 }
384 }
385 reasons
386}
387
388fn missing_claim_key_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
389 candidates
390 .iter()
391 .filter(|candidate| {
392 candidate
393 .claim_key
394 .as_ref()
395 .is_none_or(|claim_key| claim_key.trim().is_empty())
396 })
397 .map(|candidate| ResolutionReason::MissingClaimKey {
398 memory_id: candidate.memory_id.clone(),
399 })
400 .collect()
401}
402
403fn has_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
404 let claims: HashSet<_> = candidates
405 .iter()
406 .map(|candidate| normalize_claim(&candidate.claim))
407 .collect();
408 if claims.len() > 1 {
409 return true;
410 }
411
412 let ids: HashSet<_> = candidates
413 .iter()
414 .map(|candidate| candidate.memory_id.as_str())
415 .collect();
416 candidates.iter().any(|candidate| {
417 candidate
418 .conflicts_with
419 .iter()
420 .any(|conflict_id| ids.contains(conflict_id.as_str()))
421 })
422}
423
424fn high_authority_verified_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
425 candidates
426 .iter()
427 .filter(|candidate| candidate.authority.is_high_authority_verified())
428 .take(2)
429 .count()
430 > 1
431}
432
433fn strongest_candidate(candidates: &[ConflictingMemoryInput]) -> ConflictingMemoryInput {
434 let mut sorted = candidates.to_vec();
435 sorted.sort_by(|left, right| {
436 right
437 .authority
438 .authority
439 .cmp(&left.authority.authority)
440 .then_with(|| left.memory_id.cmp(&right.memory_id))
441 });
442 sorted
443 .into_iter()
444 .next()
445 .expect("strongest_candidate requires at least one candidate")
446}
447
448fn sorted_candidates(inputs: &[ConflictingMemoryInput]) -> Vec<ConflictingMemoryInput> {
449 let mut candidates = inputs.to_vec();
450 candidates.sort_by(|left, right| left.memory_id.cmp(&right.memory_id));
451 candidates
452}
453
454fn normalize_claim(claim: &str) -> String {
455 claim
456 .split_whitespace()
457 .collect::<Vec<_>>()
458 .join(" ")
459 .to_ascii_lowercase()
460}
461
462enum PrecedenceMatch {
463 One(ConflictingMemoryInput),
464 Many(Vec<String>),
465 Incomplete {
466 winner_memory_id: String,
467 missing_loser_ids: Vec<String>,
468 },
469 None,
470}
471
472fn valid_precedence_winner(
473 candidates: &[ConflictingMemoryInput],
474 precedence: &[PrecedenceEvidence],
475) -> PrecedenceMatch {
476 let candidate_ids: HashSet<_> = candidates
477 .iter()
478 .map(|candidate| candidate.memory_id.as_str())
479 .collect();
480 let mut complete_winners = Vec::new();
481 let mut first_incomplete = None;
482
483 for evidence in precedence
484 .iter()
485 .filter(|evidence| evidence.proof.is_full_chain_verified())
486 .filter(|evidence| candidate_ids.contains(evidence.winner_memory_id.as_str()))
487 {
488 let loser_ids: HashSet<_> = evidence
489 .loser_memory_ids
490 .iter()
491 .map(String::as_str)
492 .collect();
493 let mut missing_loser_ids: Vec<_> = candidate_ids
494 .iter()
495 .copied()
496 .filter(|candidate_id| *candidate_id != evidence.winner_memory_id)
497 .filter(|candidate_id| !loser_ids.contains(candidate_id))
498 .map(str::to_string)
499 .collect();
500 missing_loser_ids.sort();
501
502 if missing_loser_ids.is_empty() {
503 complete_winners.push(evidence.winner_memory_id.clone());
504 } else if first_incomplete.is_none() {
505 first_incomplete = Some(PrecedenceMatch::Incomplete {
506 winner_memory_id: evidence.winner_memory_id.clone(),
507 missing_loser_ids,
508 });
509 }
510 }
511
512 complete_winners.sort();
513 complete_winners.dedup();
514 match complete_winners.len() {
515 0 => first_incomplete.unwrap_or(PrecedenceMatch::None),
516 1 => {
517 let winner_id = &complete_winners[0];
518 let winner = candidates
519 .iter()
520 .find(|candidate| &candidate.memory_id == winner_id)
521 .expect("complete winner came from candidate IDs")
522 .clone();
523 PrecedenceMatch::One(winner)
524 }
525 _ => PrecedenceMatch::Many(complete_winners),
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 fn verified_high(memory_id: &str, claim: &str) -> ConflictingMemoryInput {
534 ConflictingMemoryInput::new(
535 memory_id,
536 Some("slot/runtime"),
537 claim,
538 AuthorityProofHint {
539 authority: AuthorityLevel::High,
540 proof: ProofClosureHint::FullChainVerified,
541 },
542 )
543 }
544
545 #[test]
546 fn conflicting_verified_memories_enter_multi_hypothesis() {
547 let left = verified_high("mem_a", "Use replay adapter version 1");
548 let right = verified_high("mem_b", "Use replay adapter version 2");
549
550 let output = resolve_conflicts(&[left, right], &[]);
551
552 assert_eq!(output.state, ResolutionState::MultiHypothesis);
553 assert_eq!(
554 output.policy_decision().final_outcome,
555 PolicyOutcome::Quarantine
556 );
557 assert_eq!(output.selected, None);
558 assert_eq!(output.hypotheses.len(), 2);
559 assert!(output
560 .reasons
561 .contains(&ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence));
562 }
563
564 #[test]
565 fn unknown_proof_propagates_unknown() {
566 let input = ConflictingMemoryInput::new(
567 "mem_unknown",
568 Some("slot/runtime"),
569 "Use replay adapter version 1",
570 AuthorityProofHint {
571 authority: AuthorityLevel::High,
572 proof: ProofClosureHint::Unknown,
573 },
574 );
575
576 let output = resolve_conflicts(&[input], &[]);
577
578 assert_eq!(output.state, ResolutionState::Unknown);
579 assert_eq!(
580 output.policy_decision().final_outcome,
581 PolicyOutcome::Quarantine
582 );
583 assert_eq!(output.selected, None);
584 assert!(output.reasons.contains(&ResolutionReason::UnknownProof {
585 memory_id: "mem_unknown".into()
586 }));
587 }
588
589 #[test]
590 fn explicit_full_chain_precedence_resolves_conflict() {
591 let left = verified_high("mem_a", "Use replay adapter version 1");
592 let right = verified_high("mem_b", "Use replay adapter version 2");
593 let precedence = PrecedenceEvidence {
594 winner_memory_id: "mem_b".into(),
595 loser_memory_ids: vec!["mem_a".into()],
596 reason: "operator-attested supersession".into(),
597 proof: ProofClosureHint::FullChainVerified,
598 };
599
600 let output = resolve_conflicts(&[left, right], &[precedence]);
601
602 assert_eq!(output.state, ResolutionState::Resolved);
603 assert_eq!(output.policy_decision().final_outcome, PolicyOutcome::Allow);
604 assert_eq!(
605 output
606 .selected
607 .as_ref()
608 .map(|candidate| candidate.memory_id.as_str()),
609 Some("mem_b")
610 );
611 assert_eq!(
612 output.reasons,
613 [ResolutionReason::ExplicitPrecedence {
614 winner_memory_id: "mem_b".into()
615 }]
616 );
617 output
618 .require_default_use_allowed()
619 .expect("resolved output is default-usable");
620 }
621
622 #[test]
623 fn broken_proof_maps_to_policy_reject() {
624 let input = ConflictingMemoryInput::new(
625 "mem_broken",
626 Some("slot/runtime"),
627 "Use replay adapter version 1",
628 AuthorityProofHint {
629 authority: AuthorityLevel::High,
630 proof: ProofClosureHint::Broken {
631 edge: "event:missing_hash".into(),
632 },
633 },
634 );
635
636 let output = resolve_conflicts(&[input], &[]);
637 let policy = output.policy_decision();
638
639 assert_eq!(output.state, ResolutionState::Unknown);
640 assert_eq!(policy.final_outcome, PolicyOutcome::Reject);
641 assert_eq!(
642 policy.contributing[0].rule_id.as_str(),
643 "retrieval.resolve.broken_proof"
644 );
645 }
646
647 #[test]
648 fn unresolved_conflict_fails_closed_for_default_use() {
649 let left = verified_high("mem_a", "Use replay adapter version 1");
650 let right = verified_high("mem_b", "Use replay adapter version 2");
651
652 let output = resolve_conflicts(&[left, right], &[]);
653 let err = output
654 .require_default_use_allowed()
655 .expect_err("multi-hypothesis output must not be default-usable");
656
657 assert!(
658 err.to_string().contains("Quarantine"),
659 "default-use failure should expose the policy outcome: {err}"
660 );
661 }
662
663 #[test]
664 fn broken_proof_fails_closed_for_default_use() {
665 let input = ConflictingMemoryInput::new(
666 "mem_broken",
667 Some("slot/runtime"),
668 "Use replay adapter version 1",
669 AuthorityProofHint {
670 authority: AuthorityLevel::High,
671 proof: ProofClosureHint::Broken {
672 edge: "event:missing_hash".into(),
673 },
674 },
675 );
676
677 let output = resolve_conflicts(&[input], &[]);
678 let err = output
679 .require_default_use_allowed()
680 .expect_err("broken proof must not be default-usable");
681
682 assert!(
683 err.to_string().contains("Reject"),
684 "default-use failure should expose the policy outcome: {err}"
685 );
686 }
687}