lean_ctx/core/execution_ledger/
verify.rs1use std::collections::{HashMap, HashSet};
4
5use super::event::ExecutionEvent;
6use super::{ExecutionLedgerError, Result};
7
8pub const GENESIS: &str = "genesis";
10
11pub 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
24pub 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 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}