Skip to main content

lean_ctx/core/execution_ledger/
verify.rs

1//! Deterministic execution-ledger hashing and chain verification.
2
3use std::collections::{HashMap, HashSet};
4
5use super::event::ExecutionEvent;
6use super::{ExecutionLedgerError, Result};
7
8/// Hash-chain anchor shared with the Savings Ledger.
9pub const GENESIS: &str = "genesis";
10
11/// Hashes one event using the Savings Ledger's SHA-256 primitive.
12///
13/// The canonical event bytes are compact JSON emitted in declaration order.  The
14/// preceding hash is also supplied as the hash primitive's domain input, matching
15/// `SavingsEvent` chaining semantics.
16pub fn hash_event(event: &ExecutionEvent) -> serde_json::Result<String> {
17    let canonical = event.hashable_json()?;
18    Ok(crate::core::savings_ledger::event::compute_hash(
19        event.prev_hash(),
20        &canonical,
21    ))
22}
23
24/// Verifies an ordered in-memory event sequence.
25pub fn verify_events(events: &[ExecutionEvent]) -> Result<bool> {
26    let mut expected_previous = GENESIS.to_string();
27    let mut expected_sequence = 1_u64;
28    let mut tasks = HashMap::<String, TaskState>::new();
29    let mut receipt_chains = HashMap::<String, (u64, String)>::new();
30    let mut canonical_receipt_ids = HashSet::<String>::new();
31
32    for (index, event) in events.iter().enumerate() {
33        if event.sequence_number() != expected_sequence {
34            return Ok(false);
35        }
36        if event.prev_hash() != expected_previous {
37            return Ok(false);
38        }
39
40        let actual_hash = hash_event(event)?;
41        if event
42            .entry_hash()
43            .is_some_and(|entry_hash| entry_hash != actual_hash)
44        {
45            return Ok(false);
46        }
47        if index > 0 && actual_hash.is_empty() {
48            return Err(ExecutionLedgerError::InvalidChain(
49                "event hash unexpectedly empty".to_owned(),
50            ));
51        }
52        if !validate_lifecycle(
53            event,
54            &mut tasks,
55            &mut receipt_chains,
56            &mut canonical_receipt_ids,
57        ) {
58            return Ok(false);
59        }
60        expected_previous = actual_hash;
61        expected_sequence = expected_sequence.checked_add(1).ok_or_else(|| {
62            ExecutionLedgerError::InvalidChain(
63                "execution ledger sequence number overflow".to_owned(),
64            )
65        })?;
66    }
67
68    Ok(true)
69}
70
71#[derive(Default)]
72struct TaskState {
73    trace_id: String,
74    plans: HashSet<String>,
75    invocations: HashSet<String>,
76    legacy_receipts: HashSet<String>,
77    canonical_receipts: HashSet<String>,
78    canonical_outcomes: HashSet<String>,
79    outcomes: HashSet<String>,
80    decisions: HashSet<String>,
81    context_delivered: bool,
82}
83
84fn validate_lifecycle(
85    event: &ExecutionEvent,
86    tasks: &mut HashMap<String, TaskState>,
87    receipt_chains: &mut HashMap<String, (u64, String)>,
88    canonical_receipt_ids: &mut HashSet<String>,
89) -> bool {
90    if event.task_id().is_empty() || event.trace_id().is_empty() || event.timestamp().is_empty() {
91        return false;
92    }
93
94    if let ExecutionEvent::TaskStarted {
95        task_id, trace_id, ..
96    } = event
97    {
98        return tasks
99            .insert(
100                task_id.clone(),
101                TaskState {
102                    trace_id: trace_id.clone(),
103                    ..TaskState::default()
104                },
105            )
106            .is_none();
107    }
108
109    let Some(state) = tasks.get_mut(event.task_id()) else {
110        return false;
111    };
112    if state.trace_id != event.trace_id() {
113        return false;
114    }
115
116    match event {
117        ExecutionEvent::PlanCreated { plan_id, .. } => {
118            state.plans.is_empty() && !plan_id.is_empty() && state.plans.insert(plan_id.clone())
119        }
120        ExecutionEvent::ContextDelivered { .. } => {
121            if state.context_delivered || state.plans.is_empty() {
122                false
123            } else {
124                state.context_delivered = true;
125                true
126            }
127        }
128        ExecutionEvent::ModelInvoked {
129            plan_id,
130            invocation_id,
131            invocation_ref,
132            provider,
133            ..
134        } => {
135            (state.context_delivered || provider == "savings-ledger")
136                && state.plans.contains(plan_id)
137                && !invocation_id.is_empty()
138                && !invocation_ref.is_empty()
139                && state.invocations.insert(invocation_id.clone())
140        }
141        ExecutionEvent::EngineInvoked {
142            plan_id,
143            invocation_id,
144            invocation_ref,
145            capability_id,
146            capability_version,
147            ..
148        } => {
149            state.context_delivered
150                && state.plans.contains(plan_id)
151                && !invocation_id.is_empty()
152                && !invocation_ref.is_empty()
153                && !capability_id.is_empty()
154                && !capability_version.is_empty()
155                && state.invocations.insert(invocation_id.clone())
156        }
157        ExecutionEvent::ReceiptSigned { receipt_id, .. } => {
158            !state.invocations.is_empty()
159                && !receipt_id.is_empty()
160                && state.legacy_receipts.insert(receipt_id.clone())
161        }
162        ExecutionEvent::CanonicalReceiptRecorded {
163            invocation_id,
164            receipt_id,
165            receipt_ref,
166            receipt_digest,
167            receipt_chain_id,
168            receipt_sequence_number,
169            previous_receipt_id,
170            previous_signature_digest,
171            ..
172        } => {
173            let predecessor_pair = (
174                previous_receipt_id.as_deref(),
175                previous_signature_digest.as_deref(),
176            );
177            let valid_predecessor = match receipt_chains.get(receipt_chain_id) {
178                None => *receipt_sequence_number == 1 && predecessor_pair == (None, None),
179                Some((sequence, head)) => {
180                    sequence.checked_add(1) == Some(*receipt_sequence_number)
181                        && previous_receipt_id.as_deref() == Some(head.as_str())
182                        && previous_signature_digest.as_deref().is_some_and(is_sha256)
183                }
184            };
185            if !state.context_delivered
186                || !state.invocations.contains(invocation_id)
187                || !is_sha256(receipt_id)
188                || !is_sha256(receipt_digest)
189                || receipt_ref != &format!("id:{receipt_digest}")
190                || receipt_chain_id.is_empty()
191                || !valid_predecessor
192                || state.canonical_receipts.contains(receipt_id)
193                || canonical_receipt_ids.contains(receipt_id)
194            {
195                return false;
196            }
197            state.canonical_receipts.insert(receipt_id.clone());
198            canonical_receipt_ids.insert(receipt_id.clone());
199            receipt_chains.insert(
200                receipt_chain_id.clone(),
201                (*receipt_sequence_number, receipt_id.clone()),
202            );
203            true
204        }
205        ExecutionEvent::OutcomeRecorded {
206            outcome_id,
207            receipt_id,
208            ..
209        } => {
210            // ReceiptSigned is legacy compatibility data. Only a canonical
211            // receipt may admit one immutable outcome. Exact bundle verification
212            // separately validates the linked document and acceptance state.
213            state.canonical_receipts.contains(receipt_id)
214                && !outcome_id.is_empty()
215                && state.canonical_outcomes.insert(receipt_id.clone())
216                && state.outcomes.insert(outcome_id.clone())
217        }
218        ExecutionEvent::DecisionRecorded { decision_id, .. } => {
219            !decision_id.is_empty() && state.decisions.insert(decision_id.clone())
220        }
221        ExecutionEvent::TaskStarted { .. } => false,
222    }
223}
224
225fn is_sha256(value: &str) -> bool {
226    value.strip_prefix("sha256:").is_some_and(|hex| {
227        hex.len() == 64
228            && hex
229                .bytes()
230                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
231    })
232}