Skip to main content

hikmah_kernel/
claims.rs

1use crate::ledger::MemoryStore;
2use crate::trace::{PrivacyClass, Trace};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use unicode_normalization::UnicodeNormalization;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ClaimConflict {
9    pub claim_key: String,
10    pub existing_trace_id: String,
11    pub existing_value: String,
12    pub incoming_value: String,
13}
14
15pub fn detect_conflicts<'a>(
16    incoming: &Trace,
17    existing: impl Iterator<Item = &'a Trace>,
18) -> Vec<ClaimConflict> {
19    let (Some(key), Some(value)) = (&incoming.claim_key, &incoming.claim_value) else {
20        return Vec::new();
21    };
22    let normalized_key = normalize_key(key);
23    let normalized_value = normalize_value(value);
24    existing
25        .filter_map(|trace| {
26            let (Some(existing_key), Some(existing_value)) = (&trace.claim_key, &trace.claim_value)
27            else {
28                return None;
29            };
30            if normalize_key(existing_key) == normalized_key
31                && normalize_value(existing_value) != normalized_value
32            {
33                Some(ClaimConflict {
34                    claim_key: key.clone(),
35                    existing_trace_id: trace.id.clone(),
36                    existing_value: existing_value.clone(),
37                    incoming_value: value.clone(),
38                })
39            } else {
40                None
41            }
42        })
43        .collect()
44}
45
46/// Ids of the traces in `others` (never `trace` itself) whose claim has the same normalized key as
47/// `trace` and a different normalized value. Sorted and de-duplicated.
48pub fn conflicting_ids<'a>(trace: &Trace, others: impl Iterator<Item = &'a Trace>) -> Vec<String> {
49    let mut ids: Vec<String> = detect_conflicts(trace, others.filter(|other| other.id != trace.id))
50        .into_iter()
51        .map(|conflict| conflict.existing_trace_id)
52        .collect();
53    ids.sort();
54    ids.dedup();
55    ids
56}
57
58/// True when both traces carry a claim with the same normalized key and different values.
59pub fn claims_conflict(a: &Trace, b: &Trace) -> bool {
60    match (&a.claim_key, &a.claim_value, &b.claim_key, &b.claim_value) {
61        (Some(ak), Some(av), Some(bk), Some(bv)) => {
62            normalize_key(ak) == normalize_key(bk) && normalize_value(av) != normalize_value(bv)
63        }
64        _ => false,
65    }
66}
67
68/// One value of a contested claim and the active traces asserting it.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ClaimValueGroup {
71    /// The normalized value (Unicode NFC, whitespace collapsed, case kept).
72    pub value: String,
73    /// Sorted by id.
74    pub trace_ids: Vec<String>,
75}
76
77/// An unresolved conflict: active traces that share a claim key but disagree on its value.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ActiveConflict {
80    /// The normalized key (Unicode NFC, whitespace collapsed, lowercase).
81    pub claim_key: String,
82    pub values: Vec<ClaimValueGroup>,
83}
84
85impl MemoryStore {
86    /// Every claim key whose active traces carry more than one normalized value. Conflicts are
87    /// derived from current state on every call, so a supersession or purge resolves them without
88    /// any extra event. Sensitive traces are left out unless the policy allows them.
89    pub fn active_conflicts(&self) -> Vec<ActiveConflict> {
90        let allow_sensitive = self.policy().allow_sensitive_persistence;
91        let mut keys: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
92        for trace in self.active_traces() {
93            if !allow_sensitive && trace.privacy == PrivacyClass::Sensitive {
94                continue;
95            }
96            let (Some(key), Some(value)) = (&trace.claim_key, &trace.claim_value) else {
97                continue;
98            };
99            keys.entry(normalize_key(key))
100                .or_default()
101                .entry(normalize_value(value))
102                .or_default()
103                .push(trace.id.clone());
104        }
105        keys.into_iter()
106            .filter(|(_, values)| values.len() > 1)
107            .map(|(claim_key, values)| ActiveConflict {
108                claim_key,
109                values: values
110                    .into_iter()
111                    .map(|(value, trace_ids)| ClaimValueGroup { value, trace_ids })
112                    .collect(),
113            })
114            .collect()
115    }
116}
117
118/// Claim keys are identifiers: Unicode NFC, whitespace-collapsed, case-insensitive.
119pub fn normalize_key(value: &str) -> String {
120    collapse(&value.nfc().collect::<String>()).to_lowercase()
121}
122
123/// Claim values keep their case (`/srv/App` and `/srv/app` are different paths) but are
124/// NFC-normalized and whitespace-collapsed, so canonically equal text never "conflicts".
125pub fn normalize_value(value: &str) -> String {
126    collapse(&value.nfc().collect::<String>())
127}
128
129fn collapse(value: &str) -> String {
130    value.split_whitespace().collect::<Vec<_>>().join(" ")
131}