Skip to main content

cel_contracts/
receipt.rs

1//! Execution receipt — the canonical, core-emitted record of one dispatched
2//! action: `intent → dispatch route → observed effect → evidence`.
3//!
4//! This is the spine of CEL's trust loop. The cortex dispatch path builds an
5//! [`ExecutionReceipt`] for every canonical action it executes; surfaces
6//! (MCP / CLI / IPC / N-API) propagate it unflattened; the run timeline
7//! appends it; the next brief + memory consume it.
8//!
9//! Before this type, the only "receipt" with route/verification detail was the
10//! MCP `ActionReceipt`, *reconstructed at the surface* from a static switch on
11//! the action kind — so it mislabels e.g. a DOM `click` routed via CDP as
12//! `native_input`. The point of this contract is that the route and the
13//! observed-effect verdict are recorded by the code that actually did the work.
14//!
15//! **Contracts policy:** types + serde only. Id / timestamp generation is a
16//! runtime concern and lives at the emission site (cortex), not here.
17
18use crate::actions::EffectExpectation;
19use crate::view::EvidenceRef;
20use serde::{Deserialize, Serialize};
21
22/// The route the runtime *actually* took to dispatch an action — observed
23/// truth, not a guess from the action kind.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "route", rename_all = "snake_case")]
26pub enum DispatchRoute {
27    /// Chrome DevTools Protocol (browser DOM).
28    Cdp,
29    /// macOS Accessibility action.
30    Accessibility,
31    /// Synthesized native input (CGEvent key / mouse).
32    NativeInput,
33    /// A registered adapter's typed action.
34    Adapter { name: String, op: String },
35    /// App focus / lifecycle (activate / launch / quit / focus lock).
36    Focus,
37    /// Not yet classified.
38    Other { detail: String },
39}
40
41/// Whether the post-dispatch effect was confirmed.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ObservedStatus {
45    /// The expected effect was observed.
46    Observed,
47    /// An effect was expected but did not materialise before timeout.
48    TimedOut,
49    /// No effect verification was requested for this action.
50    NotChecked,
51    /// The observed state contradicted the expectation.
52    Contradicted,
53}
54
55/// How the effect was (or would be) verified. Generalised beyond the browser
56/// [`EffectExpectation`] so native / adapter routes fit the same model.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum ObservedKind {
60    /// Browser DOM predicate (CDP `wait_for_effect`).
61    SelectorEffect { expectation: EffectExpectation },
62    /// An adapter's declared readback action.
63    AdapterReadback { action: String },
64    /// A native accessibility re-read.
65    AxReread,
66    /// No verification mechanism applies.
67    None,
68}
69
70/// The post-dispatch observation: did the expected effect happen?
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ObservedEffect {
73    pub status: ObservedStatus,
74    pub kind: ObservedKind,
75    /// Human-readable detail (what was observed / why it timed out).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub detail: Option<String>,
78}
79
80impl ObservedEffect {
81    /// No verification was requested for this action.
82    pub fn not_checked() -> Self {
83        Self {
84            status: ObservedStatus::NotChecked,
85            kind: ObservedKind::None,
86            detail: None,
87        }
88    }
89
90    /// A browser [`EffectExpectation`] was confirmed.
91    pub fn selector_observed(expectation: EffectExpectation) -> Self {
92        Self {
93            status: ObservedStatus::Observed,
94            kind: ObservedKind::SelectorEffect { expectation },
95            detail: None,
96        }
97    }
98
99    /// A browser [`EffectExpectation`] timed out; `detail` names what we saw
100    /// instead (the diagnostic `wait_for_effect` produced).
101    pub fn selector_timed_out(expectation: EffectExpectation, detail: impl Into<String>) -> Self {
102        Self {
103            status: ObservedStatus::TimedOut,
104            kind: ObservedKind::SelectorEffect { expectation },
105            detail: Some(detail.into()),
106        }
107    }
108}
109
110/// Terminal status of a dispatched action.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ReceiptStatus {
114    Ok,
115    Failed,
116    Vetoed,
117    Denied,
118    TimedOut,
119}
120
121/// Canonical, core-emitted record of one dispatched action.
122///
123/// One receipt per canonical action the cortex executes. Carries the intent
124/// (action kind + target), the ACTUAL dispatch route, the observed effect,
125/// evidence references, timing, and terminal status.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct ExecutionReceipt {
128    /// Process-unique id, assigned at the emission site.
129    pub receipt_id: String,
130    /// Run / session scope, when the caller or session provides one. `None`
131    /// until run-scoping lands (see plan Phase 1 / open decision #1).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub run_id: Option<String>,
134    /// Per-request trace id (reuses the IPC `trace_id` when present).
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub trace_id: Option<String>,
137    /// Action kind, e.g. `"click"`, `"set_value"`, `"type"`.
138    pub action_kind: String,
139    /// Target identifier (element id / app / url) when known.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub target: Option<String>,
142    /// The route the runtime actually took.
143    pub route: DispatchRoute,
144    /// The post-dispatch observation.
145    pub observed_effect: ObservedEffect,
146    /// Post-execution evidence references (observations / readbacks). Empty in
147    /// the first slice; populated by reference in a later phase.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub evidence: Vec<EvidenceRef>,
150    pub requested_at_ms: u64,
151    pub completed_at_ms: u64,
152    pub duration_ms: u64,
153    pub status: ReceiptStatus,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub error: Option<String>,
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::actions::EffectExpectation;
162
163    fn sample() -> ExecutionReceipt {
164        ExecutionReceipt {
165            receipt_id: "rcpt_1".into(),
166            run_id: None,
167            trace_id: Some("trace-abc".into()),
168            action_kind: "click".into(),
169            target: Some("dom:button:submit".into()),
170            route: DispatchRoute::Cdp,
171            observed_effect: ObservedEffect::selector_observed(
172                EffectExpectation::SelectorAppears {
173                    selector: ".success".into(),
174                    timeout_ms: 2_000,
175                },
176            ),
177            evidence: Vec::new(),
178            requested_at_ms: 100,
179            completed_at_ms: 250,
180            duration_ms: 150,
181            status: ReceiptStatus::Ok,
182            error: None,
183        }
184    }
185
186    #[test]
187    fn execution_receipt_round_trips() {
188        let r = sample();
189        let json = serde_json::to_string(&r).unwrap();
190        let back: ExecutionReceipt = serde_json::from_str(&json).unwrap();
191        assert_eq!(r, back);
192        assert_eq!(back.route, DispatchRoute::Cdp);
193        assert_eq!(back.observed_effect.status, ObservedStatus::Observed);
194        assert_eq!(back.duration_ms, 150);
195    }
196
197    #[test]
198    fn empty_optionals_are_omitted_from_json() {
199        // run_id / error / evidence default away so the wire stays compact and
200        // older readers that never saw them keep working.
201        let json = serde_json::to_string(&sample()).unwrap();
202        assert!(!json.contains("run_id"));
203        assert!(!json.contains("\"error\""));
204        assert!(!json.contains("evidence"));
205        assert!(json.contains("trace-abc"));
206    }
207
208    #[test]
209    fn not_checked_effect_defaults() {
210        let e = ObservedEffect::not_checked();
211        assert_eq!(e.status, ObservedStatus::NotChecked);
212        assert!(matches!(e.kind, ObservedKind::None));
213    }
214
215    #[test]
216    fn timed_out_effect_carries_detail() {
217        let e = ObservedEffect::selector_timed_out(
218            EffectExpectation::DomChanged { timeout_ms: 2_000 },
219            "no DOM change observed",
220        );
221        assert_eq!(e.status, ObservedStatus::TimedOut);
222        assert_eq!(e.detail.as_deref(), Some("no DOM change observed"));
223    }
224
225    #[test]
226    fn adapter_route_serializes_with_fields() {
227        let route = DispatchRoute::Adapter {
228            name: "numbers".into(),
229            op: "write_cells".into(),
230        };
231        let json = serde_json::to_string(&route).unwrap();
232        assert!(json.contains("\"route\":\"adapter\""));
233        let back: DispatchRoute = serde_json::from_str(&json).unwrap();
234        assert_eq!(route, back);
235    }
236}