use std::collections::HashSet;
use cortex_core::{
compose_policy_outcomes, CoreError, CoreResult, PolicyContribution, PolicyDecision,
PolicyOutcome,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolutionState {
Resolved,
MultiHypothesis,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AuthorityLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProofClosureHint {
FullChainVerified,
Partial,
Unknown,
Broken {
edge: String,
},
}
impl ProofClosureHint {
fn is_full_chain_verified(&self) -> bool {
matches!(self, Self::FullChainVerified)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityProofHint {
pub authority: AuthorityLevel,
pub proof: ProofClosureHint,
}
impl AuthorityProofHint {
#[must_use]
pub fn is_high_authority_verified(&self) -> bool {
self.authority == AuthorityLevel::High && self.proof.is_full_chain_verified()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictingMemoryInput {
pub memory_id: String,
pub claim_key: Option<String>,
pub claim: String,
pub authority: AuthorityProofHint,
pub conflicts_with: Vec<String>,
}
impl ConflictingMemoryInput {
#[must_use]
pub fn new(
memory_id: impl Into<String>,
claim_key: Option<impl Into<String>>,
claim: impl Into<String>,
authority: AuthorityProofHint,
) -> Self {
Self {
memory_id: memory_id.into(),
claim_key: claim_key.map(Into::into),
claim: claim.into(),
authority,
conflicts_with: Vec::new(),
}
}
#[must_use]
pub fn with_conflicts(mut self, conflicts_with: Vec<String>) -> Self {
self.conflicts_with = conflicts_with;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrecedenceEvidence {
pub winner_memory_id: String,
pub loser_memory_ids: Vec<String>,
pub reason: String,
pub proof: ProofClosureHint,
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolutionReason {
NoInputs,
SingleCandidate,
DuplicateClaim,
ExplicitPrecedence { winner_memory_id: String },
ConflictingClaims,
MissingClaimKey { memory_id: String },
PartialProof { memory_id: String },
UnknownProof { memory_id: String },
BrokenProof { memory_id: String, edge: String },
HighAuthorityVerifiedConflictRequiresPrecedence,
IncompletePrecedence {
winner_memory_id: String,
missing_loser_ids: Vec<String>,
},
AmbiguousPrecedence { winner_memory_ids: Vec<String> },
}
impl ResolutionReason {
const fn policy_rule_id(&self) -> &'static str {
match self {
Self::NoInputs => "retrieval.resolve.no_inputs",
Self::SingleCandidate => "retrieval.resolve.single_candidate",
Self::DuplicateClaim => "retrieval.resolve.duplicate_claim",
Self::ExplicitPrecedence { .. } => "retrieval.resolve.explicit_precedence",
Self::ConflictingClaims => "retrieval.resolve.conflicting_claims",
Self::MissingClaimKey { .. } => "retrieval.resolve.missing_claim_key",
Self::PartialProof { .. } => "retrieval.resolve.partial_proof",
Self::UnknownProof { .. } => "retrieval.resolve.unknown_proof",
Self::BrokenProof { .. } => "retrieval.resolve.broken_proof",
Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
"retrieval.resolve.high_authority_conflict_requires_precedence"
}
Self::IncompletePrecedence { .. } => "retrieval.resolve.incomplete_precedence",
Self::AmbiguousPrecedence { .. } => "retrieval.resolve.ambiguous_precedence",
}
}
const fn policy_outcome(&self, state: ResolutionState) -> PolicyOutcome {
match self {
Self::SingleCandidate | Self::DuplicateClaim | Self::ExplicitPrecedence { .. }
if matches!(state, ResolutionState::Resolved) =>
{
PolicyOutcome::Allow
}
Self::BrokenProof { .. } => PolicyOutcome::Reject,
_ => PolicyOutcome::Quarantine,
}
}
const fn policy_reason(&self) -> &'static str {
match self {
Self::NoInputs => "retrieval conflict resolver received no inputs",
Self::SingleCandidate => "single candidate can be consumed",
Self::DuplicateClaim => "duplicate claims resolved by deterministic authority ordering",
Self::ExplicitPrecedence { .. } => "explicit full-chain precedence resolved conflict",
Self::ConflictingClaims => "conflicting claims require multi-hypothesis handling",
Self::MissingClaimKey { .. } => "candidate is missing claim-key evidence",
Self::PartialProof { .. } => "candidate proof is partial",
Self::UnknownProof { .. } => "candidate proof is unknown",
Self::BrokenProof { .. } => "candidate proof is broken",
Self::HighAuthorityVerifiedConflictRequiresPrecedence => {
"high-authority verified conflict requires explicit precedence"
}
Self::IncompletePrecedence { .. } => "precedence evidence does not cover all losers",
Self::AmbiguousPrecedence { .. } => "multiple precedence winners remain ambiguous",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolverOutput {
pub state: ResolutionState,
pub selected: Option<ConflictingMemoryInput>,
pub hypotheses: Vec<ConflictingMemoryInput>,
pub reasons: Vec<ResolutionReason>,
}
impl ResolverOutput {
#[must_use]
pub fn policy_decision(&self) -> PolicyDecision {
if self.reasons.is_empty() {
return compose_policy_outcomes(
vec![PolicyContribution::new(
"retrieval.resolve.no_reason",
PolicyOutcome::Quarantine,
"resolver returned no reason and cannot be treated as clean authority",
)
.expect("static policy contribution is valid")],
None,
);
}
let contributions = self
.reasons
.iter()
.map(|reason| {
PolicyContribution::new(
reason.policy_rule_id(),
reason.policy_outcome(self.state),
reason.policy_reason(),
)
.expect("static policy contribution is valid")
})
.collect();
compose_policy_outcomes(contributions, None)
}
pub fn require_default_use_allowed(&self) -> CoreResult<()> {
let policy = self.policy_decision();
match policy.final_outcome {
PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
Err(CoreError::Validation(format!(
"retrieval resolver default use blocked by policy outcome {:?}",
policy.final_outcome
)))
}
PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
}
}
}
#[must_use]
pub fn resolve_conflicts(
inputs: &[ConflictingMemoryInput],
precedence: &[PrecedenceEvidence],
) -> ResolverOutput {
let candidates = sorted_candidates(inputs);
if candidates.is_empty() {
return ResolverOutput {
state: ResolutionState::Unknown,
selected: None,
hypotheses: Vec::new(),
reasons: vec![ResolutionReason::NoInputs],
};
}
let mut reasons = proof_reasons(&candidates);
reasons.extend(missing_claim_key_reasons(&candidates));
if !reasons.is_empty() {
return ResolverOutput {
state: ResolutionState::Unknown,
selected: None,
hypotheses: candidates,
reasons,
};
}
if candidates.len() == 1 {
return ResolverOutput {
state: ResolutionState::Resolved,
selected: candidates.first().cloned(),
hypotheses: candidates,
reasons: vec![ResolutionReason::SingleCandidate],
};
}
if !has_conflict(&candidates) {
let selected = strongest_candidate(&candidates);
return ResolverOutput {
state: ResolutionState::Resolved,
selected: Some(selected),
hypotheses: candidates,
reasons: vec![ResolutionReason::DuplicateClaim],
};
}
match valid_precedence_winner(&candidates, precedence) {
PrecedenceMatch::One(winner) => ResolverOutput {
state: ResolutionState::Resolved,
selected: Some(winner.clone()),
hypotheses: vec![winner.clone()],
reasons: vec![ResolutionReason::ExplicitPrecedence {
winner_memory_id: winner.memory_id,
}],
},
PrecedenceMatch::Many(winner_memory_ids) => ResolverOutput {
state: ResolutionState::MultiHypothesis,
selected: None,
hypotheses: candidates,
reasons: vec![ResolutionReason::AmbiguousPrecedence { winner_memory_ids }],
},
PrecedenceMatch::Incomplete {
winner_memory_id,
missing_loser_ids,
} => ResolverOutput {
state: ResolutionState::MultiHypothesis,
selected: None,
hypotheses: candidates,
reasons: vec![ResolutionReason::IncompletePrecedence {
winner_memory_id,
missing_loser_ids,
}],
},
PrecedenceMatch::None => unresolved_conflict(candidates),
}
}
fn unresolved_conflict(candidates: Vec<ConflictingMemoryInput>) -> ResolverOutput {
let mut reasons = vec![ResolutionReason::ConflictingClaims];
if high_authority_verified_conflict(&candidates) {
reasons.push(ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence);
}
ResolverOutput {
state: ResolutionState::MultiHypothesis,
selected: None,
hypotheses: candidates,
reasons,
}
}
fn proof_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
let mut reasons = Vec::new();
for candidate in candidates {
match &candidate.authority.proof {
ProofClosureHint::FullChainVerified => {}
ProofClosureHint::Partial => reasons.push(ResolutionReason::PartialProof {
memory_id: candidate.memory_id.clone(),
}),
ProofClosureHint::Unknown => reasons.push(ResolutionReason::UnknownProof {
memory_id: candidate.memory_id.clone(),
}),
ProofClosureHint::Broken { edge } => reasons.push(ResolutionReason::BrokenProof {
memory_id: candidate.memory_id.clone(),
edge: edge.clone(),
}),
}
}
reasons
}
fn missing_claim_key_reasons(candidates: &[ConflictingMemoryInput]) -> Vec<ResolutionReason> {
candidates
.iter()
.filter(|candidate| {
candidate
.claim_key
.as_ref()
.is_none_or(|claim_key| claim_key.trim().is_empty())
})
.map(|candidate| ResolutionReason::MissingClaimKey {
memory_id: candidate.memory_id.clone(),
})
.collect()
}
fn has_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
let claims: HashSet<_> = candidates
.iter()
.map(|candidate| normalize_claim(&candidate.claim))
.collect();
if claims.len() > 1 {
return true;
}
let ids: HashSet<_> = candidates
.iter()
.map(|candidate| candidate.memory_id.as_str())
.collect();
candidates.iter().any(|candidate| {
candidate
.conflicts_with
.iter()
.any(|conflict_id| ids.contains(conflict_id.as_str()))
})
}
fn high_authority_verified_conflict(candidates: &[ConflictingMemoryInput]) -> bool {
candidates
.iter()
.filter(|candidate| candidate.authority.is_high_authority_verified())
.take(2)
.count()
> 1
}
fn strongest_candidate(candidates: &[ConflictingMemoryInput]) -> ConflictingMemoryInput {
let mut sorted = candidates.to_vec();
sorted.sort_by(|left, right| {
right
.authority
.authority
.cmp(&left.authority.authority)
.then_with(|| left.memory_id.cmp(&right.memory_id))
});
sorted
.into_iter()
.next()
.expect("strongest_candidate requires at least one candidate")
}
fn sorted_candidates(inputs: &[ConflictingMemoryInput]) -> Vec<ConflictingMemoryInput> {
let mut candidates = inputs.to_vec();
candidates.sort_by(|left, right| left.memory_id.cmp(&right.memory_id));
candidates
}
fn normalize_claim(claim: &str) -> String {
claim
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
enum PrecedenceMatch {
One(ConflictingMemoryInput),
Many(Vec<String>),
Incomplete {
winner_memory_id: String,
missing_loser_ids: Vec<String>,
},
None,
}
fn valid_precedence_winner(
candidates: &[ConflictingMemoryInput],
precedence: &[PrecedenceEvidence],
) -> PrecedenceMatch {
let candidate_ids: HashSet<_> = candidates
.iter()
.map(|candidate| candidate.memory_id.as_str())
.collect();
let mut complete_winners = Vec::new();
let mut first_incomplete = None;
for evidence in precedence
.iter()
.filter(|evidence| evidence.proof.is_full_chain_verified())
.filter(|evidence| candidate_ids.contains(evidence.winner_memory_id.as_str()))
{
let loser_ids: HashSet<_> = evidence
.loser_memory_ids
.iter()
.map(String::as_str)
.collect();
let mut missing_loser_ids: Vec<_> = candidate_ids
.iter()
.copied()
.filter(|candidate_id| *candidate_id != evidence.winner_memory_id)
.filter(|candidate_id| !loser_ids.contains(candidate_id))
.map(str::to_string)
.collect();
missing_loser_ids.sort();
if missing_loser_ids.is_empty() {
complete_winners.push(evidence.winner_memory_id.clone());
} else if first_incomplete.is_none() {
first_incomplete = Some(PrecedenceMatch::Incomplete {
winner_memory_id: evidence.winner_memory_id.clone(),
missing_loser_ids,
});
}
}
complete_winners.sort();
complete_winners.dedup();
match complete_winners.len() {
0 => first_incomplete.unwrap_or(PrecedenceMatch::None),
1 => {
let winner_id = &complete_winners[0];
let winner = candidates
.iter()
.find(|candidate| &candidate.memory_id == winner_id)
.expect("complete winner came from candidate IDs")
.clone();
PrecedenceMatch::One(winner)
}
_ => PrecedenceMatch::Many(complete_winners),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn verified_high(memory_id: &str, claim: &str) -> ConflictingMemoryInput {
ConflictingMemoryInput::new(
memory_id,
Some("slot/runtime"),
claim,
AuthorityProofHint {
authority: AuthorityLevel::High,
proof: ProofClosureHint::FullChainVerified,
},
)
}
#[test]
fn conflicting_verified_memories_enter_multi_hypothesis() {
let left = verified_high("mem_a", "Use replay adapter version 1");
let right = verified_high("mem_b", "Use replay adapter version 2");
let output = resolve_conflicts(&[left, right], &[]);
assert_eq!(output.state, ResolutionState::MultiHypothesis);
assert_eq!(
output.policy_decision().final_outcome,
PolicyOutcome::Quarantine
);
assert_eq!(output.selected, None);
assert_eq!(output.hypotheses.len(), 2);
assert!(output
.reasons
.contains(&ResolutionReason::HighAuthorityVerifiedConflictRequiresPrecedence));
}
#[test]
fn unknown_proof_propagates_unknown() {
let input = ConflictingMemoryInput::new(
"mem_unknown",
Some("slot/runtime"),
"Use replay adapter version 1",
AuthorityProofHint {
authority: AuthorityLevel::High,
proof: ProofClosureHint::Unknown,
},
);
let output = resolve_conflicts(&[input], &[]);
assert_eq!(output.state, ResolutionState::Unknown);
assert_eq!(
output.policy_decision().final_outcome,
PolicyOutcome::Quarantine
);
assert_eq!(output.selected, None);
assert!(output.reasons.contains(&ResolutionReason::UnknownProof {
memory_id: "mem_unknown".into()
}));
}
#[test]
fn explicit_full_chain_precedence_resolves_conflict() {
let left = verified_high("mem_a", "Use replay adapter version 1");
let right = verified_high("mem_b", "Use replay adapter version 2");
let precedence = PrecedenceEvidence {
winner_memory_id: "mem_b".into(),
loser_memory_ids: vec!["mem_a".into()],
reason: "operator-attested supersession".into(),
proof: ProofClosureHint::FullChainVerified,
};
let output = resolve_conflicts(&[left, right], &[precedence]);
assert_eq!(output.state, ResolutionState::Resolved);
assert_eq!(output.policy_decision().final_outcome, PolicyOutcome::Allow);
assert_eq!(
output
.selected
.as_ref()
.map(|candidate| candidate.memory_id.as_str()),
Some("mem_b")
);
assert_eq!(
output.reasons,
[ResolutionReason::ExplicitPrecedence {
winner_memory_id: "mem_b".into()
}]
);
output
.require_default_use_allowed()
.expect("resolved output is default-usable");
}
#[test]
fn broken_proof_maps_to_policy_reject() {
let input = ConflictingMemoryInput::new(
"mem_broken",
Some("slot/runtime"),
"Use replay adapter version 1",
AuthorityProofHint {
authority: AuthorityLevel::High,
proof: ProofClosureHint::Broken {
edge: "event:missing_hash".into(),
},
},
);
let output = resolve_conflicts(&[input], &[]);
let policy = output.policy_decision();
assert_eq!(output.state, ResolutionState::Unknown);
assert_eq!(policy.final_outcome, PolicyOutcome::Reject);
assert_eq!(
policy.contributing[0].rule_id.as_str(),
"retrieval.resolve.broken_proof"
);
}
#[test]
fn unresolved_conflict_fails_closed_for_default_use() {
let left = verified_high("mem_a", "Use replay adapter version 1");
let right = verified_high("mem_b", "Use replay adapter version 2");
let output = resolve_conflicts(&[left, right], &[]);
let err = output
.require_default_use_allowed()
.expect_err("multi-hypothesis output must not be default-usable");
assert!(
err.to_string().contains("Quarantine"),
"default-use failure should expose the policy outcome: {err}"
);
}
#[test]
fn broken_proof_fails_closed_for_default_use() {
let input = ConflictingMemoryInput::new(
"mem_broken",
Some("slot/runtime"),
"Use replay adapter version 1",
AuthorityProofHint {
authority: AuthorityLevel::High,
proof: ProofClosureHint::Broken {
edge: "event:missing_hash".into(),
},
},
);
let output = resolve_conflicts(&[input], &[]);
let err = output
.require_default_use_allowed()
.expect_err("broken proof must not be default-usable");
assert!(
err.to_string().contains("Reject"),
"default-use failure should expose the policy outcome: {err}"
);
}
}