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