Skip to main content

bobby_browser_client/
outcomes.rs

1//! Command outcomes, evidence, and accessibility snapshot nodes.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{AttemptId, CommandId, PageId};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11pub struct ControlActionEvidence {
12    pub operation: crate::FormControlOperation,
13    pub target: crate::FormControlTarget,
14    pub state: crate::FormControlState,
15    pub validity: crate::FormControlValidity,
16    pub node_replaced: bool,
17    /// Controls that appeared because of this action (conditional fields,
18    /// e.g. a billing-cycle select revealed by a plan choice). Read this
19    /// before re-snapshotting: the targets are passable to `control_action`
20    /// verbatim.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub revealed_controls: Vec<crate::forms::RevealedControl>,
23}
24
25/// Semantic target for accessibility-based commands (role + accessible name).
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct AccessibilityTarget {
30    pub role: String,
31    pub accessible_name: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub ordinal: Option<usize>,
34    /// Frame hops from the main frame to the document holding this node;
35    /// empty for main-frame nodes. Segments are the same role/name/ordinal
36    /// shape `FormControlTarget.frame_path` resolves, so the target can be
37    /// passed verbatim to `control_action`.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub frame_path: Vec<crate::forms::SemanticTargetSegment>,
40}
41
42/// Where the retained page context says a described control is.
43///
44/// Must stay in `types`: the crate-boundary guard requires every wire-advertised
45/// shape to be a `types::` one so the schema parity guard covers it.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48#[serde(rename_all = "camelCase")]
49pub struct ContextAnswer {
50    pub target: AccessibilityTarget,
51    pub confidence: f32,
52    /// Whether the answer was observed live this session (stamped with the
53    /// page's generation) or remembered from a prior session's persisted
54    /// context. Additive; absent means a live generation-0 answer.
55    #[serde(default)]
56    pub observed_at: ContextObservedAt,
57    /// How the underlying record entered the graph. Absent for live answers
58    /// (always direct observation).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub source: Option<ContextAnswerSource>,
61}
62
63impl ContextAnswer {
64    /// Retained and live context answers both originate from page observations.
65    pub fn page_derived(&self) -> bool {
66        true
67    }
68}
69
70impl Default for ContextObservedAt {
71    fn default() -> Self {
72        Self::Generation { generation: 0 }
73    }
74}
75
76/// Provenance of a [`ContextAnswer`]: live-observed under a page generation,
77/// or remembered from the persisted per-profile context store.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[serde(tag = "kind", rename_all = "camelCase")]
81pub enum ContextObservedAt {
82    Generation { generation: u64 },
83    Persisted,
84}
85
86/// How a remembered control record entered the graph.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[serde(rename_all = "kebab-case")]
90pub enum ContextAnswerSource {
91    Observed,
92    VisionPromoted,
93}
94
95/// The remembered form structure around a located control (`context_neighbors`).
96/// Structure only: roles, names, ordinals, and per-intent counters — never
97/// values, page text, or timestamps finer than a day.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[serde(rename_all = "camelCase")]
101pub struct ContextNeighbors {
102    pub answer: ContextAnswer,
103    /// Key of the enclosing form within the remembered page.
104    pub form: String,
105    /// Pattern of the remembered page the form belongs to.
106    pub page_pattern: String,
107    pub controls: Vec<ContextNeighborControl>,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112#[serde(rename_all = "camelCase")]
113pub struct ContextNeighborControl {
114    pub role: String,
115    pub accessible_name: String,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub ordinal: Option<usize>,
118    /// Per-intent-kind counters, keyed by intent kind.
119    pub intents: std::collections::BTreeMap<String, ContextNeighborStats>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124#[serde(rename_all = "camelCase")]
125pub struct ContextNeighborStats {
126    pub success_count: u64,
127    pub failure_count: u64,
128    /// Days since the Unix epoch of the last verified success.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub last_verified_day: Option<u32>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub source: Option<ContextAnswerSource>,
133}
134
135/// One remembered site's structure: page pattern → form key → controls.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138#[serde(rename_all = "camelCase")]
139pub struct ContextSiteView {
140    pub site_key: String,
141    pub pages: std::collections::BTreeMap<
142        String,
143        std::collections::BTreeMap<String, Vec<ContextNeighborControl>>,
144    >,
145}
146
147/// Machine-readable reason returned when retained context has no answer.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub enum ContextMissReason {
151    #[serde(rename = "notRemembered")]
152    NotRemembered,
153}
154
155/// Repair step returned with a retained-context miss.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158pub enum ContextNextStep {
159    #[serde(rename = "a11y_snapshot")]
160    A11ySnapshot,
161}
162
163/// Response from `GET /v1/context/ask`.
164#[derive(Debug, Clone, PartialEq, Serialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct ContextAskResponse {
168    pub answer: Option<ContextAnswer>,
169    pub hit: bool,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub reason: Option<ContextMissReason>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub next_step: Option<ContextNextStep>,
174}
175
176#[derive(Deserialize)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178struct ContextAskResponseWire {
179    answer: Option<ContextAnswer>,
180    hit: bool,
181    #[serde(default)]
182    reason: Option<ContextMissReason>,
183    #[serde(default)]
184    next_step: Option<ContextNextStep>,
185    #[serde(default)]
186    page_derived: Option<bool>,
187}
188
189impl<'de> Deserialize<'de> for ContextAskResponse {
190    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
191        let wire = ContextAskResponseWire::deserialize(deserializer)?;
192        if wire.page_derived == Some(false) {
193            return Err(serde::de::Error::custom("pageDerived must be true"));
194        }
195        Ok(Self {
196            answer: wire.answer,
197            hit: wire.hit,
198            reason: wire.reason,
199            next_step: wire.next_step,
200        })
201    }
202}
203
204impl ContextAskResponse {
205    pub fn page_derived(&self) -> bool {
206        true
207    }
208}
209
210/// Response from `GET /v1/context/neighbors`.
211#[derive(Debug, Clone, PartialEq, Serialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct ContextNeighborsResponse {
215    pub neighbors: Option<ContextNeighbors>,
216    pub hit: bool,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub reason: Option<ContextMissReason>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub next_step: Option<ContextNextStep>,
221}
222
223#[derive(Deserialize)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225struct ContextNeighborsResponseWire {
226    neighbors: Option<ContextNeighbors>,
227    hit: bool,
228    #[serde(default)]
229    reason: Option<ContextMissReason>,
230    #[serde(default)]
231    next_step: Option<ContextNextStep>,
232    #[serde(default)]
233    page_derived: Option<bool>,
234}
235
236impl<'de> Deserialize<'de> for ContextNeighborsResponse {
237    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
238        let wire = ContextNeighborsResponseWire::deserialize(deserializer)?;
239        if wire.page_derived == Some(false) {
240            return Err(serde::de::Error::custom("pageDerived must be true"));
241        }
242        Ok(Self {
243            neighbors: wire.neighbors,
244            hit: wire.hit,
245            reason: wire.reason,
246            next_step: wire.next_step,
247        })
248    }
249}
250
251impl ContextNeighborsResponse {
252    pub fn page_derived(&self) -> bool {
253        true
254    }
255}
256
257/// Response from `GET /v1/context/site/{key}`.
258#[derive(Debug, Clone, PartialEq, Serialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260#[serde(rename_all = "camelCase", deny_unknown_fields)]
261pub struct ContextSiteResponse {
262    pub site: Option<ContextSiteView>,
263}
264
265#[derive(Deserialize)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267struct ContextSiteResponseWire {
268    site: Option<ContextSiteView>,
269    #[serde(default)]
270    page_derived: Option<bool>,
271}
272
273impl<'de> Deserialize<'de> for ContextSiteResponse {
274    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
275        let wire = ContextSiteResponseWire::deserialize(deserializer)?;
276        if wire.page_derived == Some(false) {
277            return Err(serde::de::Error::custom("pageDerived must be true"));
278        }
279        Ok(Self { site: wire.site })
280    }
281}
282
283impl ContextSiteResponse {
284    pub fn page_derived(&self) -> bool {
285        true
286    }
287}
288
289/// One node in an `accessibilitySnapshot` result tree.
290#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[serde(rename_all = "camelCase", deny_unknown_fields)]
293pub struct AccessibilityNode {
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub role: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub name: Option<String>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub target: Option<AccessibilityTarget>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub value: Option<String>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub description: Option<String>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub required: Option<bool>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub disabled: Option<bool>,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub read_only: Option<bool>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub invalid: Option<bool>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub checked: Option<bool>,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub autocomplete: Option<String>,
316    /// Link destination, when the engine reports one for this node. Lets an
317    /// agent feed `download_url` (or navigate) without an HTML round-trip.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub url: Option<String>,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub value_min: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub value_max: Option<String>,
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    pub children: Vec<AccessibilityNode>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330#[serde(
331    tag = "status",
332    rename_all = "camelCase",
333    rename_all_fields = "camelCase"
334)]
335pub enum CommandOutcome {
336    Completed {
337        command_id: CommandId,
338        evidence: Vec<Evidence>,
339    },
340    RetryableFailure {
341        command_id: CommandId,
342        error: CommandError,
343    },
344    NeedsReconciliation {
345        command_id: CommandId,
346        error: CommandError,
347        evidence: Vec<Evidence>,
348    },
349    PolicyDenied {
350        command_id: CommandId,
351        error: CommandError,
352    },
353    ResourceExhausted {
354        command_id: CommandId,
355        error: CommandError,
356        retry_after_ms: u64,
357    },
358    Restarted {
359        command_id: CommandId,
360        prior_attempt_id: AttemptId,
361        attempt_id: AttemptId,
362        reason: String,
363        #[serde(default)]
364        evidence: Vec<Evidence>,
365    },
366    Failed {
367        command_id: CommandId,
368        error: CommandError,
369        #[serde(default)]
370        evidence: Vec<Evidence>,
371    },
372}
373
374/// Upper bound on [`Evidence::Wait`]'s `observed` value, in characters.
375///
376/// A verification value -- a matched label, a URL, a ready state -- not page
377/// text. Truncation is on a character boundary; a byte index can land inside a
378/// multi-byte codepoint and panic, which is exactly how extraction used to die
379/// on non-ASCII pages.
380pub const MAX_WAIT_OBSERVED_CHARS: usize = 512;
381
382#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
383#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
384#[serde(rename_all = "camelCase")]
385pub enum SubmitSettlementOutcome {
386    Settled,
387    ValidationRejected,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
391#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
392#[serde(
393    tag = "kind",
394    rename_all = "camelCase",
395    rename_all_fields = "camelCase"
396)]
397pub enum Evidence {
398    ExecutionPath {
399        path: ExecutionPath,
400        reason: ExecutionReason,
401        state_version: u64,
402        elapsed_ms: u64,
403        bytes: Option<u64>,
404        sha256: Option<String>,
405        #[serde(default, skip_serializing_if = "Option::is_none")]
406        final_url: Option<String>,
407        #[serde(default, skip_serializing_if = "Option::is_none")]
408        content_type: Option<String>,
409        #[serde(default, skip_serializing_if = "Option::is_none")]
410        status: Option<u16>,
411        #[serde(default, skip_serializing_if = "Vec::is_empty")]
412        redirect_chain: Vec<String>,
413    },
414    Navigation {
415        url: String,
416        title: String,
417    },
418    Inspection {
419        selector: Option<String>,
420        url: String,
421        title: String,
422        text: String,
423        html: Option<String>,
424    },
425    /// Result of a boundary submit that used `networkQuiet` as its
426    /// postcondition. This classifies the settled page without requiring the
427    /// caller to predict confirmation copy or risk a second submit.
428    SubmitSettlement {
429        outcome: SubmitSettlementOutcome,
430    },
431    Element {
432        selector: String,
433        text: Option<String>,
434    },
435    Upload {
436        selector: String,
437        paths: Vec<String>,
438    },
439    Page {
440        page_id: PageId,
441        url: String,
442        title: String,
443    },
444    /// Retained page-context generation after command bookkeeping.
445    PageGeneration {
446        page_id: PageId,
447        generation: u64,
448    },
449    Pages {
450        pages: Vec<PageEvidence>,
451    },
452    Popup {
453        opener_page_id: PageId,
454        page_id: PageId,
455        url: String,
456        title: String,
457    },
458    /// A handle-resolved call found its bound page no longer open (a popup
459    /// the handle was following, since closed) and fell back to the opener
460    /// the earlier `click_and_wait_for_popup` recorded. `popup_page_id` is
461    /// the page that is now gone; `opener_page_id` is the page the handle is
462    /// bound to going forward. Carried on a completed read-only retry, or
463    /// on the original `failed` outcome when the call was mutating.
464    PopupClosed {
465        popup_page_id: PageId,
466        opener_page_id: PageId,
467    },
468    Download {
469        filename: String,
470        path: String,
471        bytes: u64,
472        sha256: String,
473        /// The exact caller-supplied destination after it passed the configured
474        /// downloads-root policy. Absolute input is echoed as absolute; relative
475        /// input remains relative. The runtime never discovers or exposes a path
476        /// the caller did not already provide.
477        #[serde(default, skip_serializing_if = "Option::is_none")]
478        saved_to: Option<String>,
479    },
480    Configuration {
481        name: String,
482        value: String,
483    },
484    Resolution {
485        target: Box<crate::TargetSpec>,
486        fingerprint: Box<TargetFingerprint>,
487        candidates: Vec<CandidateEvidence>,
488        best_match_authorized: bool,
489    },
490    Wait {
491        condition: crate::WaitCondition,
492        elapsed_ms: u64,
493        observations: u64,
494        #[serde(default, skip_serializing_if = "Vec::is_empty")]
495        excluded_classes: Vec<String>,
496        /// What the satisfying poll actually read: the element text or value,
497        /// the URL, or the document ready state, depending on the condition.
498        ///
499        /// The wait already reads this to decide whether it is satisfied. It
500        /// used to be discarded, so an agent that verified a submit had to
501        /// spend a second round trip snapshotting the page to learn what it
502        /// had just confirmed. Bounded by [`MAX_WAIT_OBSERVED_CHARS`] -- this
503        /// is a verification value, never a page-text dump.
504        #[serde(default, skip_serializing_if = "Option::is_none")]
505        observed: Option<String>,
506    },
507    Screenshot {
508        artifact_id: String,
509        media_type: String,
510        width: u32,
511        height: u32,
512        bytes: u64,
513        sha256: String,
514    },
515    BrowserExecution {
516        engine: String,
517        browser_version: String,
518        profile_id: String,
519        interaction_path: String,
520    },
521    JavaScriptResult {
522        value: serde_json::Value,
523        truncated: bool,
524    },
525    AccessibilitySnapshot {
526        page_id: PageId,
527        nodes: Vec<AccessibilityNode>,
528        truncated: bool,
529    },
530    FormSnapshot {
531        snapshot: crate::FormSnapshot,
532    },
533    FormValidation {
534        issues: Vec<crate::FormValidationIssue>,
535    },
536    ControlAction {
537        action: ControlActionEvidence,
538    },
539    StructuredExtraction {
540        page_id: PageId,
541        value: serde_json::Value,
542        truncated: bool,
543    },
544    /// A challenge classification from `detectChallenge`: `Some` is a
545    /// detected challenge, `None` is a provably clean page. `confidence` is
546    /// the model's confidence in its answer either way — a caller gating a
547    /// solve spend on a clean answer needs it. `prior_kind` records the site
548    /// prior that enriched the prompt when one existed — transparency, never
549    /// a blended answer.
550    ChallengeDetection {
551        confidence: f32,
552        detection: Option<crate::challenges::ChallengeDetection>,
553        #[serde(default, skip_serializing_if = "Option::is_none")]
554        prior_kind: Option<String>,
555    },
556    CookieState {
557        page_id: Option<PageId>,
558        cookies: Vec<crate::CookieRecord>,
559    },
560    PdfArtifact {
561        artifact_id: String,
562        media_type: String,
563        bytes: u64,
564        sha256: String,
565    },
566    Dialog {
567        dialog_type: String,
568        message: String,
569        action: String,
570    },
571    Emulation {
572        viewport: Option<crate::ViewportSize>,
573        geolocation: Option<crate::GeolocationCoordinates>,
574    },
575    HarArtifact {
576        artifact_id: String,
577        media_type: String,
578        bytes: u64,
579        sha256: String,
580        entries: u32,
581    },
582    IntentExecution {
583        record: ExecutionRecord,
584    },
585    /// Input timing the runtime synthesized rather than observed, emitted when
586    /// the session opted into `executionPolicy.humanize`. Lets `intent-engine`
587    /// verify an effect against the timing actually issued. Carries no typed
588    /// text: action counts and durations only.
589    Humanization {
590        engine: String,
591        actions: u32,
592        synthesized_ms: u64,
593    },
594    /// Result of resolving one named field of an `ExtractIntent`, emitted once
595    /// per field in field order. `value: None` means unresolved, with the
596    /// reason in `errorCode`; the rest of the extraction still runs.
597    Extraction {
598        field: String,
599        #[serde(default, skip_serializing_if = "Option::is_none")]
600        value: Option<String>,
601        resolution_path: IntentResolutionPath,
602        #[serde(default, skip_serializing_if = "Option::is_none")]
603        error_code: Option<ErrorCode>,
604    },
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
609#[serde(rename_all = "camelCase")]
610pub enum IntentResolutionPath {
611    Deterministic,
612    VisionFallback,
613    /// Resolved from a cached proactive vision proposal.
614    VisionPrefill,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619#[serde(rename_all = "camelCase")]
620pub struct ExecutionRecord {
621    pub intent_kind: String,
622    pub purpose: Option<String>,
623    pub resolution_path: IntentResolutionPath,
624    pub plan_summary: String,
625    pub candidates: Vec<CandidateEvidence>,
626    pub wait_elapsed_ms: Option<u64>,
627    pub verification: String,
628    pub artifact_ids: Vec<String>,
629    pub vision_proposal_sha256: Option<String>,
630}
631
632impl Evidence {
633    pub fn page_derived(&self) -> bool {
634        matches!(
635            self,
636            Self::Inspection { .. }
637                | Self::AccessibilitySnapshot { .. }
638                | Self::FormSnapshot { .. }
639                | Self::StructuredExtraction { .. }
640        )
641    }
642
643    pub fn journal_safe(&self) -> Self {
644        fn safe_url(value: &str) -> String {
645            let Ok(mut url) = url::Url::parse(value) else {
646                return "[redacted-invalid-url]".into();
647            };
648            let _ = url.set_username("");
649            let _ = url.set_password(None);
650            url.set_query(None);
651            url.set_fragment(None);
652            url.to_string()
653        }
654        let mut safe = self.clone();
655        match &mut safe {
656            Self::ExecutionPath {
657                final_url,
658                redirect_chain,
659                ..
660            } => {
661                if let Some(url) = final_url {
662                    *url = safe_url(url);
663                }
664                for url in redirect_chain {
665                    *url = safe_url(url);
666                }
667            }
668            Self::Navigation { url, .. }
669            | Self::Inspection { url, .. }
670            | Self::Page { url, .. }
671            | Self::Popup { url, .. } => *url = safe_url(url),
672            Self::Pages { pages } => {
673                for page in pages {
674                    page.url = safe_url(&page.url);
675                }
676            }
677            Self::Upload { paths, .. } => {
678                for (index, path) in paths.iter_mut().enumerate() {
679                    *path = format!("upload://evidence/{index}");
680                }
681            }
682            Self::Download {
683                path,
684                sha256,
685                saved_to,
686                ..
687            } => {
688                *path = format!("artifact://sha256/{sha256}");
689                *saved_to = None;
690            }
691            Self::PopupClosed { .. } => {}
692            Self::BrowserExecution { .. } => {}
693            Self::IntentExecution { .. } => {}
694            Self::Extraction { .. } => {}
695            _ => {}
696        }
697        safe
698    }
699}
700
701/// Add provenance only to page-derived evidence in a serialized outcome.
702/// This keeps public Rust enum variants source-compatible while extending the
703/// HTTP and MCP JSON representations.
704pub fn annotate_page_derived_evidence(outcome: &mut serde_json::Value) {
705    let Some(evidence) = outcome
706        .get_mut("evidence")
707        .and_then(serde_json::Value::as_array_mut)
708    else {
709        return;
710    };
711    for item in evidence {
712        if matches!(
713            item.get("kind").and_then(serde_json::Value::as_str),
714            Some("inspection" | "accessibilitySnapshot" | "formSnapshot" | "structuredExtraction")
715        ) {
716            item["pageDerived"] = serde_json::Value::Bool(true);
717        }
718    }
719}
720
721impl CommandOutcome {
722    pub fn journal_safe(&self) -> Self {
723        let mut safe = self.clone();
724        match &mut safe {
725            Self::Completed { evidence, .. }
726            | Self::NeedsReconciliation { evidence, .. }
727            | Self::Restarted { evidence, .. }
728            | Self::Failed { evidence, .. } => {
729                *evidence = evidence.iter().map(Evidence::journal_safe).collect();
730            }
731            _ => {}
732        }
733        match &mut safe {
734            Self::RetryableFailure { error, .. }
735            | Self::NeedsReconciliation { error, .. }
736            | Self::PolicyDenied { error, .. }
737            | Self::ResourceExhausted { error, .. }
738            | Self::Failed { error, .. } => error.message = "redacted durable diagnostic".into(),
739            _ => {}
740        }
741        safe
742    }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
747#[serde(rename_all = "camelCase")]
748/// Which strategy served a command: the direct-HTTP reader, or the live
749/// browser. The browser variants name the *strategy*, never an engine — a
750/// Firefox-companion run reports `browser`, and reporting `chromium` there
751/// contradicted the `browserExecution` evidence sitting beside it. The old
752/// wire names still deserialize so recorded journals keep replaying.
753pub enum ExecutionPath {
754    DirectHttp,
755    #[serde(alias = "chromium")]
756    Browser,
757    #[serde(alias = "chromiumFallback")]
758    BrowserFallback,
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763#[serde(rename_all = "camelCase")]
764pub enum ExecutionReason {
765    EligibleStaticDocument,
766    EligibleExplicitDownload,
767    IneligibleCommand,
768    SemanticTargetRequired,
769    JavascriptRequired,
770    UnsupportedContentType,
771    StateConflict,
772    PolicyRequired,
773    /// The page has mutated since load (a non-read-only command ran against
774    /// it), so a whole-page read must come from the live DOM, not a refetch.
775    PageMutated,
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
779#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
780#[serde(rename_all = "camelCase")]
781pub struct CandidateEvidence {
782    pub role: Option<String>,
783    pub name: Option<String>,
784    pub score: i32,
785    pub reasons: Vec<String>,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
789#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
790#[serde(rename_all = "camelCase")]
791pub struct TargetFingerprint {
792    pub page_id: PageId,
793    pub frame: Option<String>,
794    pub role: Option<String>,
795    pub name: Option<String>,
796    pub stable_attributes: std::collections::BTreeMap<String, String>,
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
800#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
801#[serde(rename_all = "camelCase")]
802pub struct PageEvidence {
803    pub page_id: PageId,
804    pub url: String,
805    pub title: String,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
809#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
810#[serde(rename_all = "camelCase")]
811pub struct CommandError {
812    pub code: ErrorCode,
813    pub message: String,
814    pub layer: ErrorLayer,
815    pub retryable: bool,
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
819#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
820#[serde(rename_all = "camelCase")]
821pub enum ErrorCode {
822    InvalidRequest,
823    NotFound,
824    DeadlineExceeded,
825    BrowserLaunchFailed,
826    BrowserCommandFailed,
827    VerificationFailed,
828    JournalFailed,
829    ResourceExhausted,
830    PolicyDenied,
831    Internal,
832    TargetNotFound,
833    TargetAmbiguous,
834    FrameNotFound,
835    ShadowRootUnavailable,
836    TargetDetached,
837    TargetObscured,
838    TargetOutOfBounds,
839    WaitConditionTimedOut,
840    ScreenshotCaptureFailed,
841    NetworkPolicyDenied,
842    HttpResponseTooLarge,
843    HttpTransferFailed,
844    HttpStateConflict,
845    HttpEquivalenceUnproven,
846    IntentCompileFailed,
847    IntentActionMismatch,
848    ObstructionSuspected,
849    VisionAssistDenied,
850    VisionAssistFailed,
851    // `submitAndVerify`'s expected state already held before the submit ran,
852    // so a post-act pass would prove nothing (static-copy matcher).
853    ExpectedStatePreSatisfied,
854    // A Boundary submit already completed within this workflow; running
855    // another would double-apply the effect unless the caller explicitly
856    // acknowledges the prior one (`reSubmit`).
857    BoundaryAlreadyExecuted,
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
861#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
862#[serde(rename_all = "camelCase")]
863pub enum ErrorLayer {
864    Interface,
865    Broker,
866    Workflow,
867    Page,
868    Driver,
869    Browser,
870    Network,
871    Site,
872    Journal,
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
877#[serde(rename_all = "camelCase")]
878pub enum CommandPhase {
879    Accepted,
880    Prepared,
881    Executing,
882    ResultPrepared,
883    Verifying,
884    Recovering,
885    Completed,
886    Failed,
887}
888
889#[derive(Debug, Error, Serialize, Deserialize)]
890#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
891pub enum RuntimeError {
892    #[error("not found: {0}")]
893    NotFound(String),
894    #[error("invalid request: {0}")]
895    InvalidRequest(String),
896    /// The configured browser engine could not be reached or started. An
897    /// environment fault, not a bad call: the caller's arguments were fine and
898    /// resubmitting them unchanged is the right move once the engine is up.
899    #[error("{0}")]
900    EngineUnreachable(String),
901    #[error("internal error: {0}")]
902    Internal(String),
903}