Skip to main content

lean_ctx/core/execution_ledger/
event.rs

1//! Task-aware execution events.
2//!
3//! The execution ledger deliberately keeps the event payloads small.  References to
4//! envelopes, plans, receipts, and decisions are persisted instead of copying their
5//! bodies into the ledger.  The chain fields are part of every variant so an event can
6//! be serialized and verified without a second side table.
7
8use serde::{Deserialize, Serialize};
9
10use super::verify::hash_event;
11
12pub use lean_ctx_protocol::ContextBalanceV1 as ContextBalance;
13/// The tri-state acceptance value used by [`ExecutionEvent::OutcomeRecorded`].
14///
15/// This is an alias, rather than a second enum, so execution events use the same
16/// wire representation as `AcceptedOutcomeV1`.
17pub use lean_ctx_protocol::{AcceptanceState as TriState, ContextBalanceV1};
18
19/// One append-only observation in the execution ledger.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub enum ExecutionEvent {
22    TaskStarted {
23        task_id: String,
24        trace_id: String,
25        envelope_ref: String,
26        timestamp: String,
27        sequence_number: u64,
28        prev_hash: String,
29    },
30    PlanCreated {
31        task_id: String,
32        trace_id: String,
33        plan_id: String,
34        plan_ref: String,
35        timestamp: String,
36        sequence_number: u64,
37        prev_hash: String,
38    },
39    ContextDelivered {
40        task_id: String,
41        trace_id: String,
42        context_balance: ContextBalanceV1,
43        timestamp: String,
44        sequence_number: u64,
45        prev_hash: String,
46    },
47    ModelInvoked {
48        task_id: String,
49        trace_id: String,
50        plan_id: String,
51        invocation_id: String,
52        invocation_ref: String,
53        model: String,
54        provider: String,
55        tokens_in: u64,
56        tokens_out: u64,
57        latency_ms: u64,
58        timestamp: String,
59        sequence_number: u64,
60        prev_hash: String,
61    },
62    EngineInvoked {
63        task_id: String,
64        trace_id: String,
65        plan_id: String,
66        invocation_id: String,
67        invocation_ref: String,
68        capability_id: String,
69        capability_version: String,
70        timestamp: String,
71        sequence_number: u64,
72        prev_hash: String,
73    },
74    ReceiptSigned {
75        task_id: String,
76        trace_id: String,
77        receipt_id: String,
78        receipt_hash: String,
79        signature: String,
80        timestamp: String,
81        sequence_number: u64,
82        prev_hash: String,
83    },
84    CanonicalReceiptRecorded {
85        task_id: String,
86        trace_id: String,
87        invocation_id: String,
88        receipt_id: String,
89        receipt_ref: String,
90        receipt_digest: String,
91        receipt_chain_id: String,
92        receipt_sequence_number: u64,
93        previous_receipt_id: Option<String>,
94        previous_signature_digest: Option<String>,
95        timestamp: String,
96        sequence_number: u64,
97        prev_hash: String,
98        entry_hash: String,
99    },
100    OutcomeRecorded {
101        task_id: String,
102        trace_id: String,
103        outcome_id: String,
104        receipt_id: String,
105        accepted: TriState,
106        timestamp: String,
107        sequence_number: u64,
108        prev_hash: String,
109    },
110    DecisionRecorded {
111        task_id: String,
112        trace_id: String,
113        decision_id: String,
114        kind: String,
115        selected: String,
116        timestamp: String,
117        sequence_number: u64,
118        prev_hash: String,
119    },
120}
121
122impl ExecutionEvent {
123    /// Stable logical identity for append retries that must not duplicate events.
124    #[must_use]
125    pub(crate) fn idempotency_key(&self) -> Option<(&'static str, &str, &str)> {
126        match self {
127            Self::TaskStarted { task_id, .. } => Some(("task", task_id, "")),
128            Self::PlanCreated {
129                task_id, plan_id, ..
130            } => Some(("plan", task_id, plan_id)),
131            Self::ModelInvoked {
132                task_id,
133                invocation_id,
134                ..
135            }
136            | Self::EngineInvoked {
137                task_id,
138                invocation_id,
139                ..
140            } => Some(("invocation", task_id, invocation_id)),
141            Self::ReceiptSigned {
142                task_id,
143                receipt_id,
144                ..
145            } => Some(("receipt", task_id, receipt_id)),
146            Self::CanonicalReceiptRecorded { receipt_ref, .. } => {
147                Some(("canonical_receipt", receipt_ref, ""))
148            }
149            Self::OutcomeRecorded {
150                task_id,
151                outcome_id,
152                ..
153            } => Some(("outcome", task_id, outcome_id)),
154            Self::DecisionRecorded {
155                task_id,
156                decision_id,
157                ..
158            } => Some(("decision", task_id, decision_id)),
159            Self::ContextDelivered { task_id, .. } => Some(("context", task_id, "")),
160        }
161    }
162
163    /// Canonical payload bytes excluding store-assigned chain metadata.
164    pub(crate) fn payload_json(&self) -> serde_json::Result<String> {
165        let mut event = self.clone();
166        event.set_chain_fields(0, String::new());
167        event.set_entry_hash(String::new());
168        serde_json::to_string(&event)
169    }
170
171    /// Returns the event's chain sequence number.
172    #[must_use]
173    pub const fn sequence_number(&self) -> u64 {
174        match self {
175            Self::TaskStarted {
176                sequence_number, ..
177            }
178            | Self::PlanCreated {
179                sequence_number, ..
180            }
181            | Self::ContextDelivered {
182                sequence_number, ..
183            }
184            | Self::ModelInvoked {
185                sequence_number, ..
186            }
187            | Self::EngineInvoked {
188                sequence_number, ..
189            }
190            | Self::ReceiptSigned {
191                sequence_number, ..
192            }
193            | Self::CanonicalReceiptRecorded {
194                sequence_number, ..
195            }
196            | Self::OutcomeRecorded {
197                sequence_number, ..
198            }
199            | Self::DecisionRecorded {
200                sequence_number, ..
201            } => *sequence_number,
202        }
203    }
204
205    /// Returns the hash of the preceding event recorded on this event.
206    #[must_use]
207    pub fn prev_hash(&self) -> &str {
208        match self {
209            Self::TaskStarted { prev_hash, .. }
210            | Self::PlanCreated { prev_hash, .. }
211            | Self::ContextDelivered { prev_hash, .. }
212            | Self::ModelInvoked { prev_hash, .. }
213            | Self::EngineInvoked { prev_hash, .. }
214            | Self::ReceiptSigned { prev_hash, .. }
215            | Self::CanonicalReceiptRecorded { prev_hash, .. }
216            | Self::OutcomeRecorded { prev_hash, .. }
217            | Self::DecisionRecorded { prev_hash, .. } => prev_hash,
218        }
219    }
220
221    /// Returns the task identity carried by this event.
222    #[must_use]
223    pub fn task_id(&self) -> &str {
224        match self {
225            Self::TaskStarted { task_id, .. }
226            | Self::PlanCreated { task_id, .. }
227            | Self::ContextDelivered { task_id, .. }
228            | Self::ModelInvoked { task_id, .. }
229            | Self::EngineInvoked { task_id, .. }
230            | Self::ReceiptSigned { task_id, .. }
231            | Self::CanonicalReceiptRecorded { task_id, .. }
232            | Self::OutcomeRecorded { task_id, .. }
233            | Self::DecisionRecorded { task_id, .. } => task_id,
234        }
235    }
236
237    /// Returns the distributed trace identity carried by this event.
238    #[must_use]
239    pub fn trace_id(&self) -> &str {
240        match self {
241            Self::TaskStarted { trace_id, .. }
242            | Self::PlanCreated { trace_id, .. }
243            | Self::ContextDelivered { trace_id, .. }
244            | Self::ModelInvoked { trace_id, .. }
245            | Self::EngineInvoked { trace_id, .. }
246            | Self::ReceiptSigned { trace_id, .. }
247            | Self::CanonicalReceiptRecorded { trace_id, .. }
248            | Self::OutcomeRecorded { trace_id, .. }
249            | Self::DecisionRecorded { trace_id, .. } => trace_id,
250        }
251    }
252
253    /// Returns the observation timestamp supplied by the caller.
254    #[must_use]
255    pub fn timestamp(&self) -> &str {
256        match self {
257            Self::TaskStarted { timestamp, .. }
258            | Self::PlanCreated { timestamp, .. }
259            | Self::ContextDelivered { timestamp, .. }
260            | Self::ModelInvoked { timestamp, .. }
261            | Self::EngineInvoked { timestamp, .. }
262            | Self::ReceiptSigned { timestamp, .. }
263            | Self::CanonicalReceiptRecorded { timestamp, .. }
264            | Self::OutcomeRecorded { timestamp, .. }
265            | Self::DecisionRecorded { timestamp, .. } => timestamp,
266        }
267    }
268
269    /// Serializes the event in compact canonical JSON form.
270    ///
271    /// Struct and enum field order is fixed by the Rust declaration and
272    /// `serde_json::to_string` emits no insignificant whitespace, giving the
273    /// ledger a stable byte representation without relying on map iteration order.
274    pub fn canonical_json(&self) -> serde_json::Result<String> {
275        serde_json::to_string(self)
276    }
277
278    /// Canonical event bytes whose digest is stored on canonical receipt events.
279    pub(crate) fn hashable_json(&self) -> serde_json::Result<String> {
280        let mut event = self.clone();
281        event.set_entry_hash(String::new());
282        serde_json::to_string(&event)
283    }
284
285    /// Stored self-authenticating hash for the P2 canonical receipt event.
286    #[must_use]
287    pub(crate) fn entry_hash(&self) -> Option<&str> {
288        match self {
289            Self::CanonicalReceiptRecorded { entry_hash, .. } => Some(entry_hash),
290            _ => None,
291        }
292    }
293
294    pub(crate) fn set_entry_hash(&mut self, value: String) {
295        if let Self::CanonicalReceiptRecorded { entry_hash, .. } = self {
296            *entry_hash = value;
297        }
298    }
299
300    /// Computes this event's chain hash using the Savings Ledger SHA-256 primitive.
301    pub fn event_hash(&self) -> serde_json::Result<String> {
302        hash_event(self)
303    }
304
305    /// Fills the chain metadata immediately before append.
306    pub(crate) fn set_chain_fields(&mut self, sequence_number: u64, prev_hash: String) {
307        match self {
308            Self::TaskStarted {
309                sequence_number: sequence,
310                prev_hash: previous,
311                ..
312            }
313            | Self::PlanCreated {
314                sequence_number: sequence,
315                prev_hash: previous,
316                ..
317            }
318            | Self::ContextDelivered {
319                sequence_number: sequence,
320                prev_hash: previous,
321                ..
322            }
323            | Self::ModelInvoked {
324                sequence_number: sequence,
325                prev_hash: previous,
326                ..
327            }
328            | Self::EngineInvoked {
329                sequence_number: sequence,
330                prev_hash: previous,
331                ..
332            }
333            | Self::ReceiptSigned {
334                sequence_number: sequence,
335                prev_hash: previous,
336                ..
337            }
338            | Self::CanonicalReceiptRecorded {
339                sequence_number: sequence,
340                prev_hash: previous,
341                ..
342            }
343            | Self::OutcomeRecorded {
344                sequence_number: sequence,
345                prev_hash: previous,
346                ..
347            }
348            | Self::DecisionRecorded {
349                sequence_number: sequence,
350                prev_hash: previous,
351                ..
352            } => {
353                *sequence = sequence_number;
354                *previous = prev_hash;
355            }
356        }
357    }
358}