Skip to main content

pointlock_ir/
runtime.rs

1//! Runtime wire types shared between the runner and providers.
2//!
3//! Definition home adjudicated to `pointlock-ir` (type truth source, R12) so
4//! that `pointlock-store` (which depends only on `ir`) can persist them
5//! without violating the spine §1.2 dependency direction; pending spine
6//! batch incorporation.
7//!
8//! Shapes follow spine §4.2 (Provider SPI signatures) and Appendix A.8
9//! (DeviceRail passthrough vocabulary). DeviceRail-shaped types here are the
10//! Pointlock-side projections the provider adapter maps wire payloads onto;
11//! exact field-level alignment with the DeviceRail protocol schemas is
12//! re-verified when `devicerail-client` lands (M1).
13
14use std::collections::BTreeMap;
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::vocab::{
21    CoordinateFallbackReason, ScreenshotOmissionReason, UiContextKind, UiSnapshotOmissionReason,
22    VerdictStatus,
23};
24
25/// Content-addressed evidence reference (DeviceRail `AssetRef`, spine A.8).
26/// Never inline bytes; `sha256` is the bare hex digest when present.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct AssetRef {
30    /// Provider-scoped asset id (e.g. `sha256:<digest>` or an opaque id).
31    pub id: String,
32    /// Media type, e.g. `image/png`.
33    pub media_type: String,
34    /// Provider URI, e.g. `devicerail://assets/sha256/<digest>`.
35    pub uri: String,
36    /// Bare lowercase hex sha256 digest of the content, when known.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub sha256: Option<String>,
39}
40
41/// A typed failed-localization record (2026-07-18 incorporation, item ③):
42/// the declared asset plus why its bytes could not be localized. Gaps are
43/// data, never silent omissions (principle 4/R4).
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct EvidenceGap {
47    /// The declared asset, verbatim.
48    pub asset: AssetRef,
49    /// Why localization failed.
50    pub reason: String,
51}
52
53/// A localized evidence entry: the provider [`AssetRef`] plus the local
54/// content-addressed copy (spine §6.6 — evidence is localized during
55/// `observing` because provider-side retention is not guaranteed).
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct EvidenceRef {
59    /// The provider-side reference this entry was localized from.
60    pub asset: AssetRef,
61    /// Bare lowercase hex sha256 digest of the localized bytes.
62    pub sha256: String,
63    /// Path inside the store's content-addressed evidence area.
64    pub local_path: String,
65}
66
67/// Viewport of an observation (DeviceRail `Viewport`).
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct Viewport {
71    /// Width in device-independent pixels.
72    pub width: u64,
73    /// Height in device-independent pixels.
74    pub height: u64,
75    /// Device scale factor.
76    pub scale_factor: f64,
77}
78
79/// Full identity of one UI context (DeviceRail `UiContextRef`, spine A.8).
80/// `documentEpoch` changes on navigation/reconnect and invalidates prior
81/// node references.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct UiContextRef {
85    /// Native accessibility or web document context.
86    pub context_kind: UiContextKind,
87    /// Provider-scoped context id.
88    pub context_id: String,
89    /// Epoch token; a mismatch means prior `UiNodeRef`s are stale.
90    pub document_epoch: String,
91}
92
93/// Reference to one node of a captured UI snapshot (DeviceRail `UiNodeRef`).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct UiNodeRef {
97    /// The observation this node was captured in.
98    pub observation_id: String,
99    /// The UI context (carries the `documentEpoch` staleness token).
100    pub context: UiContextRef,
101    /// Stable node id inside the snapshot.
102    pub stable_node_id: String,
103}
104
105/// Reference to the UI-tree evidence of an observation. Minimal shape
106/// (the evidence asset); exact DeviceRail `UiSnapshotRef` field alignment is
107/// re-verified in M1 — pending incorporation.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct UiSnapshotRef {
111    /// The normalized UI-tree evidence asset.
112    pub evidence: AssetRef,
113}
114
115/// A judgement-free point-in-time world snapshot (spine §2 concept 9,
116/// DeviceRail `Observation` shape). Omissions are typed data, not errors.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct Observation {
120    /// Provider-scoped observation id.
121    pub id: String,
122    /// Device the observation was captured on.
123    pub device_id: String,
124    /// Capture timestamp (ms since epoch).
125    pub captured_at_ms: u64,
126    /// Viewport at capture time.
127    pub viewport: Viewport,
128    /// Screenshot evidence, when captured.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub screenshot: Option<AssetRef>,
131    /// Why the screenshot was legitimately omitted.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub screenshot_omission: Option<ScreenshotOmissionReason>,
134    /// UI-tree evidence, when captured.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub ui_snapshot: Option<UiSnapshotRef>,
137    /// Why the UI snapshot was legitimately omitted.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
140    /// Provider metadata passthrough.
141    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142    pub metadata: BTreeMap<String, Value>,
143}
144
145/// Structured, stable-coded error (DeviceRail `ErrorInfo`, spine A.8).
146/// `retryable` is advisory metadata for the runner — providers never retry.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "camelCase", deny_unknown_fields)]
149pub struct ErrorInfo {
150    /// Stable error code (open string set on the wire; mapped to the closed
151    /// `ErrorClass` by the provider adapter, spine §5).
152    pub code: String,
153    /// Human-readable message.
154    pub message: String,
155    /// Whether the originator considers the failure retryable.
156    pub retryable: bool,
157    /// Sanitized structured details.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub details: Option<Value>,
160}
161
162/// The execution mode the provider actually used (spine §4.2) — the key
163/// input for unauthorized-degradation auditing (§6.4 R-degrade).
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
165#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
166pub enum ActionExecution {
167    /// Native semantic channel.
168    #[serde(rename_all = "camelCase")]
169    NativeSemantic {
170        /// The UI context the action ran in.
171        context: UiContextRef,
172    },
173    /// Web semantic channel.
174    #[serde(rename_all = "camelCase")]
175    WebSemantic {
176        /// The UI context the action ran in.
177        context: UiContextRef,
178    },
179    /// Daemon-internal coordinate fallback (must be whitelisted by
180    /// `acceptExecutionModes`, otherwise the verdict degrades).
181    #[serde(rename_all = "camelCase")]
182    CoordinateFallback {
183        /// The UI context the action ran in.
184        context: UiContextRef,
185        /// Why the daemon fell back.
186        fallback_reason: CoordinateFallbackReason,
187    },
188}
189
190/// Result of a succeeded action (spine §4.2). Every action returns state
191/// deltas: `before`/`after` observations plus evidence references.
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct ActionResult {
195    /// The caller-generated action id (`device.execute` params.id).
196    pub call_id: String,
197    /// Dispatch timestamp (ms since epoch).
198    pub started_at_ms: u64,
199    /// Terminal timestamp (ms since epoch).
200    pub finished_at_ms: u64,
201    /// Action output (e.g. `findElement` → `{ element: UiNodeRef }`).
202    pub output: Value,
203    /// World snapshot before the action, when captured.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub before: Option<Observation>,
206    /// World snapshot after the action, when captured.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub after: Option<Observation>,
209    /// Evidence produced by the action.
210    pub evidence: Vec<AssetRef>,
211    /// The execution mode the provider reports having used.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub execution: Option<ActionExecution>,
214}
215
216/// Four-way terminal outcome of an action (spine §4.2; never folded,
217/// never translated — `cancelled` and `timedOut` are recorded terminals).
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
219#[serde(tag = "outcome", rename_all = "camelCase", deny_unknown_fields)]
220pub enum ActionOutcome {
221    /// The action completed and reported a result.
222    #[serde(rename_all = "camelCase")]
223    Succeeded {
224        /// The action result (boxed: much larger than the error variants).
225        result: Box<ActionResult>,
226    },
227    /// The action failed with a structured error.
228    #[serde(rename_all = "camelCase")]
229    Failed {
230        /// The structured failure.
231        error: ErrorInfo,
232    },
233    /// The action was cooperatively cancelled.
234    #[serde(rename_all = "camelCase")]
235    Cancelled {
236        /// The structured cancellation record.
237        error: ErrorInfo,
238    },
239    /// The action-scoped budget elapsed.
240    #[serde(rename_all = "camelCase")]
241    TimedOut {
242        /// The structured timeout record.
243        error: ErrorInfo,
244    },
245}
246
247impl ActionOutcome {
248    /// The wire discriminant of this outcome.
249    pub fn kind(&self) -> &'static str {
250        match self {
251            ActionOutcome::Succeeded { .. } => "succeeded",
252            ActionOutcome::Failed { .. } => "failed",
253            ActionOutcome::Cancelled { .. } => "cancelled",
254            ActionOutcome::TimedOut { .. } => "timedOut",
255        }
256    }
257}
258
259/// Fate of a hanging action intent after a crash (spine §4.2/§6.7-B).
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
261#[serde(tag = "fate", rename_all = "camelCase", deny_unknown_fields)]
262pub enum ReconcileResult {
263    /// A terminal event for the callId is on record: adopt it. The archived
264    /// terminal is the full four-way [`ActionOutcome`] — a recorded
265    /// `failed`/`cancelled`/`timedOut` is a *certain* fate and comes back
266    /// verbatim, never demoted to an uncertain branch.
267    #[serde(rename_all = "camelCase")]
268    Completed {
269        /// The recorded terminal outcome (boxed: much larger than the
270        /// other variants).
271        outcome: Box<ActionOutcome>,
272    },
273    /// The issuing session's full event range shows no trace: safe replay.
274    NeverDispatched,
275    /// A start without a terminal (theoretically shielded against);
276    /// treated as the uncertain branch.
277    StartedNoTerminal,
278    /// The issuing session's log is unreachable: the uncertain branch.
279    #[serde(rename_all = "camelCase")]
280    LogUnavailable {
281        /// Why the log could not be read.
282        reason: String,
283    },
284}
285
286/// A folded step verdict (spine §2 concept 12). Append-only history:
287/// re-judgement produces a *new* verdict with `supersedes` set.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290pub struct Verdict {
291    /// Three-valued status (unknown never folds to pass).
292    pub status: VerdictStatus,
293    /// Whether the pass rests on a degraded verify channel or an
294    /// unauthorized provider-internal fallback (spine §6.3/§6.4).
295    pub degraded: bool,
296    /// Human-readable folding summary (provider write path caps this at
297    /// 16384 chars, spine §4.2).
298    pub summary: String,
299    /// Evidence citations (provider write path caps at 64, spine §4.2).
300    pub evidence: Vec<AssetRef>,
301    /// Id of the verdict this one supersedes, for re-judgement lineage.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub supersedes: Option<String>,
304}
305
306/// The durable per-step verdict projection stored in `StepRecord`
307/// (spine §6.6 — the folded summary/evidence live in the RunLog's
308/// `verdictRecorded` payload; the record keeps the alignment-relevant core).
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
310#[serde(rename_all = "camelCase", deny_unknown_fields)]
311pub struct StepVerdict {
312    /// Three-valued status.
313    pub status: VerdictStatus,
314    /// Degradation flag (see [`Verdict::degraded`]).
315    pub degraded: bool,
316    /// Id of the superseded verdict, for re-judgement lineage.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub supersedes: Option<String>,
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::json;
325
326    #[test]
327    fn action_outcome_wire_discriminants_round_trip() {
328        let error = ErrorInfo {
329            code: "action_timeout".to_owned(),
330            message: "budget elapsed".to_owned(),
331            retryable: true,
332            details: None,
333        };
334        let timed_out = ActionOutcome::TimedOut {
335            error: error.clone(),
336        };
337        let wire = serde_json::to_value(&timed_out).expect("serialize");
338        assert_eq!(wire["outcome"], "timedOut");
339        assert_eq!(wire["error"]["retryable"], true);
340        let back: ActionOutcome = serde_json::from_value(wire).expect("deserialize");
341        assert_eq!(back, timed_out);
342        assert_eq!(back.kind(), "timedOut");
343
344        let cancelled = ActionOutcome::Cancelled { error };
345        assert_eq!(
346            serde_json::to_value(&cancelled).expect("serialize")["outcome"],
347            "cancelled"
348        );
349    }
350
351    #[test]
352    fn reconcile_fates_round_trip() {
353        let unavailable = ReconcileResult::LogUnavailable {
354            reason: "session deleted".to_owned(),
355        };
356        let wire = serde_json::to_value(&unavailable).expect("serialize");
357        assert_eq!(
358            wire,
359            json!({"fate": "logUnavailable", "reason": "session deleted"})
360        );
361        let never: ReconcileResult =
362            serde_json::from_value(json!({"fate": "neverDispatched"})).expect("deserialize");
363        assert_eq!(never, ReconcileResult::NeverDispatched);
364    }
365
366    #[test]
367    fn reconcile_completed_carries_the_four_way_outcome_verbatim() {
368        let completed = ReconcileResult::Completed {
369            outcome: Box::new(ActionOutcome::Failed {
370                error: ErrorInfo {
371                    code: "device_unavailable".to_owned(),
372                    message: "device went away".to_owned(),
373                    retryable: true,
374                    details: None,
375                },
376            }),
377        };
378        let wire = serde_json::to_value(&completed).expect("serialize");
379        assert_eq!(wire["fate"], "completed");
380        assert_eq!(wire["outcome"]["outcome"], "failed");
381        assert_eq!(wire["outcome"]["error"]["retryable"], true);
382        let back: ReconcileResult = serde_json::from_value(wire).expect("deserialize");
383        assert_eq!(back, completed);
384    }
385
386    #[test]
387    fn observation_omissions_are_camel_case_and_absent_when_none() {
388        let observation = Observation {
389            id: "obs-1".to_owned(),
390            device_id: "dev-1".to_owned(),
391            captured_at_ms: 1,
392            viewport: Viewport {
393                width: 1080,
394                height: 2400,
395                scale_factor: 2.0,
396            },
397            screenshot: None,
398            screenshot_omission: Some(ScreenshotOmissionReason::ProtectedAction),
399            ui_snapshot: None,
400            ui_snapshot_omission: Some(UiSnapshotOmissionReason::DriverUnsupported),
401            metadata: BTreeMap::new(),
402        };
403        let wire = serde_json::to_value(&observation).expect("serialize");
404        assert_eq!(wire["screenshotOmission"], "protectedAction");
405        assert_eq!(wire["uiSnapshotOmission"], "driverUnsupported");
406        assert!(wire.get("screenshot").is_none());
407        assert!(wire.get("metadata").is_none());
408        let back: Observation = serde_json::from_value(wire).expect("deserialize");
409        assert_eq!(back, observation);
410    }
411
412    #[test]
413    fn execution_mode_fallback_reason_round_trips() {
414        let execution = ActionExecution::CoordinateFallback {
415            context: UiContextRef {
416                context_kind: UiContextKind::Native,
417                context_id: "ctx-1".to_owned(),
418                document_epoch: "epoch-1".to_owned(),
419            },
420            fallback_reason: CoordinateFallbackReason::SemanticInteractionUnavailable,
421        };
422        let wire = serde_json::to_value(&execution).expect("serialize");
423        assert_eq!(wire["mode"], "coordinateFallback");
424        assert_eq!(wire["fallbackReason"], "semanticInteractionUnavailable");
425        let back: ActionExecution = serde_json::from_value(wire).expect("deserialize");
426        assert_eq!(back, execution);
427    }
428}