Skip to main content

glass/
task_protocol.rs

1//! Strict, bounded authored tasks for deterministic Web IR compilation.
2//!
3//! This module defines the input boundary only. Validation is side-effect free;
4//! compilation and browser execution remain separate follow-on layers.
5
6use crate::web_ir::DraftEntityKind;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt::{Display, Formatter};
11
12/// Version of the authored Task Protocol contract.
13pub const TASK_PROTOCOL_SCHEMA_VERSION: u32 = 1;
14const MAX_INPUTS: usize = 64;
15const MAX_INPUT_NAME_BYTES: usize = 64;
16const MAX_INPUT_VALUE_BYTES: usize = 4_096;
17const MAX_POSTCONDITIONS: usize = 32;
18const MAX_EXPECTATION_BYTES: usize = 256;
19const MAX_ACTIONS: u32 = 256;
20const MAX_TIMEOUT_MS: u64 = 120_000;
21const MAX_ITEMS: u32 = 4_096;
22
23/// Supported deterministic task families.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum TaskKind {
26    #[serde(rename = "form.inspect")]
27    FormInspect,
28    #[serde(rename = "form.fill")]
29    FormFill,
30    #[serde(rename = "form.validate")]
31    FormValidate,
32    #[serde(rename = "form.submit")]
33    FormSubmit,
34    #[serde(rename = "navigation.follow")]
35    NavigationFollow,
36    #[serde(rename = "navigation.selectTab")]
37    NavigationSelectTab,
38    #[serde(rename = "table.extract")]
39    TableExtract,
40    #[serde(rename = "collection.extract")]
41    CollectionExtract,
42    #[serde(rename = "region.extract")]
43    RegionExtract,
44    #[serde(rename = "field.read")]
45    FieldRead,
46    #[serde(rename = "dialog.inspect")]
47    DialogInspect,
48    #[serde(rename = "dialog.confirm")]
49    DialogConfirm,
50    #[serde(rename = "dialog.cancel")]
51    DialogCancel,
52    #[serde(rename = "pagination.next")]
53    PaginationNext,
54    #[serde(rename = "pagination.collect")]
55    PaginationCollect,
56}
57
58/// Effect class declared by the task author.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub enum TaskRiskClass {
62    ReadOnly,
63    LocalMutation,
64    RemoteReversible,
65    RemoteIrreversible,
66    Authentication,
67    DataDisclosure,
68    UnknownRisk,
69}
70
71/// Behavior when semantic resolution returns more than one candidate.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(rename_all = "camelCase")]
74pub enum TaskAmbiguityPolicy {
75    #[default]
76    Fail,
77    RequireConfirmation,
78}
79
80/// Revision policy requested by the task author.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "camelCase")]
83pub enum TaskRevisionPolicy {
84    #[default]
85    Exact,
86    Compatible,
87    Reextract,
88}
89
90/// Typed postcondition families reserved for the compiler and verifier.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub enum TaskPostconditionKind {
94    PageKind,
95    RegionPresent,
96    EntityState,
97    NavigationOccurred,
98    DialogClosed,
99    ValidationClear,
100    RecordsExtracted,
101}
102
103/// Semantic task scope; it contains no selectors or browser handles.
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct TaskScope {
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub region_name: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub entity_kind: Option<DraftEntityKind>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub entity_name: Option<String>,
113}
114
115/// Bounded execution limits declared before compilation.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct TaskLimits {
119    pub max_actions: u32,
120    pub timeout_ms: u64,
121    pub max_items: u32,
122}
123
124impl Default for TaskLimits {
125    fn default() -> Self {
126        Self {
127            max_actions: 16,
128            timeout_ms: 15_000,
129            max_items: 128,
130        }
131    }
132}
133
134/// One typed success condition for a compiled task.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct TaskPostcondition {
138    pub kind: TaskPostconditionKind,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub expected: Option<String>,
141}
142
143/// Versioned declarative task input accepted by Glass.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct GlassTask {
147    pub schema_version: u32,
148    pub task: TaskKind,
149    pub scope: TaskScope,
150    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
151    pub inputs: BTreeMap<String, String>,
152    pub limits: TaskLimits,
153    pub risk: TaskRiskClass,
154    #[serde(default)]
155    pub ambiguity: TaskAmbiguityPolicy,
156    #[serde(default)]
157    pub revision: TaskRevisionPolicy,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub postconditions: Vec<TaskPostcondition>,
160}
161
162impl GlassTask {
163    /// Parse strict authored JSON into a task.
164    pub fn from_json(input: &str) -> Result<Self, TaskProtocolError> {
165        let task: Self = serde_json::from_str(input)
166            .map_err(|error| TaskProtocolError::new("$", error.to_string()))?;
167        task.validate()?;
168        Ok(task)
169    }
170
171    /// Validate the task without browser access or mutation.
172    pub fn validate(&self) -> Result<(), TaskProtocolError> {
173        if self.schema_version != TASK_PROTOCOL_SCHEMA_VERSION {
174            return Err(TaskProtocolError::new(
175                "schemaVersion",
176                "unsupported Task Protocol schema version",
177            ));
178        }
179        self.scope.validate()?;
180        if self.inputs.len() > MAX_INPUTS {
181            return Err(TaskProtocolError::new(
182                "inputs",
183                "input count exceeds the Task Protocol bound",
184            ));
185        }
186        for (name, value) in &self.inputs {
187            validate_text("inputs.name", name, MAX_INPUT_NAME_BYTES)?;
188            if value.len() > MAX_INPUT_VALUE_BYTES || value.chars().any(char::is_control) {
189                return Err(TaskProtocolError::new(
190                    format!("inputs.{name}"),
191                    "input value exceeds its bound or contains a control character",
192                ));
193            }
194        }
195        if !(1..=MAX_ACTIONS).contains(&self.limits.max_actions) {
196            return Err(TaskProtocolError::new(
197                "limits.maxActions",
198                "maxActions must be between 1 and 256",
199            ));
200        }
201        if !(1..=MAX_TIMEOUT_MS).contains(&self.limits.timeout_ms) {
202            return Err(TaskProtocolError::new(
203                "limits.timeoutMs",
204                "timeoutMs must be between 1 and 120000",
205            ));
206        }
207        if !(1..=MAX_ITEMS).contains(&self.limits.max_items) {
208            return Err(TaskProtocolError::new(
209                "limits.maxItems",
210                "maxItems must be between 1 and 4096",
211            ));
212        }
213        if self.postconditions.len() > MAX_POSTCONDITIONS {
214            return Err(TaskProtocolError::new(
215                "postconditions",
216                "postcondition count exceeds the Task Protocol bound",
217            ));
218        }
219        for (index, postcondition) in self.postconditions.iter().enumerate() {
220            if let Some(expected) = &postcondition.expected
221                && (expected.len() > MAX_EXPECTATION_BYTES
222                    || expected.chars().any(char::is_control))
223            {
224                return Err(TaskProtocolError::new(
225                    format!("postconditions[{index}].expected"),
226                    "expected value exceeds its bound or contains a control character",
227                ));
228            }
229        }
230        if matches!(self.task, TaskKind::FormFill) && self.inputs.is_empty() {
231            return Err(TaskProtocolError::new(
232                "inputs",
233                "form.fill requires at least one bounded input",
234            ));
235        }
236        Ok(())
237    }
238
239    /// Serialize a validated task deterministically.
240    pub fn to_canonical_json(&self) -> Result<String, TaskProtocolError> {
241        self.validate()?;
242        serde_json::to_string(self).map_err(|error| TaskProtocolError::new("$", error.to_string()))
243    }
244}
245
246impl TaskScope {
247    fn validate(&self) -> Result<(), TaskProtocolError> {
248        if self.region_name.is_none() && self.entity_kind.is_none() && self.entity_name.is_none() {
249            return Err(TaskProtocolError::new(
250                "scope",
251                "scope requires a semantic region or entity constraint",
252            ));
253        }
254        if let Some(region_name) = &self.region_name {
255            validate_text("scope.regionName", region_name, 128)?;
256        }
257        if let Some(entity_name) = &self.entity_name {
258            validate_text("scope.entityName", entity_name, 128)?;
259        }
260        if self.entity_name.is_some() && self.entity_kind.is_none() {
261            return Err(TaskProtocolError::new(
262                "scope.entityKind",
263                "entityName requires entityKind",
264            ));
265        }
266        Ok(())
267    }
268}
269
270fn validate_text(path: &str, value: &str, max_bytes: usize) -> Result<(), TaskProtocolError> {
271    if value.is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
272        return Err(TaskProtocolError::new(
273            path,
274            "value must be non-empty, bounded, and free of control characters",
275        ));
276    }
277    Ok(())
278}
279
280/// Path-aware Task Protocol validation failure.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct TaskProtocolError {
283    pub path: String,
284    pub reason: String,
285}
286
287impl TaskProtocolError {
288    fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
289        Self {
290            path: path.into(),
291            reason: reason.into(),
292        }
293    }
294}
295
296impl Display for TaskProtocolError {
297    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
298        write!(formatter, "{}: {}", self.path, self.reason)
299    }
300}
301
302impl Error for TaskProtocolError {}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_json::json;
308
309    fn task() -> GlassTask {
310        GlassTask {
311            schema_version: TASK_PROTOCOL_SCHEMA_VERSION,
312            task: TaskKind::FormFill,
313            scope: TaskScope {
314                region_name: Some("Shipping address".into()),
315                entity_kind: Some(DraftEntityKind::Form),
316                entity_name: None,
317            },
318            inputs: BTreeMap::from([(String::from("city"), String::from("Kuching"))]),
319            limits: TaskLimits::default(),
320            risk: TaskRiskClass::LocalMutation,
321            ambiguity: TaskAmbiguityPolicy::Fail,
322            revision: TaskRevisionPolicy::Exact,
323            postconditions: vec![TaskPostcondition {
324                kind: TaskPostconditionKind::ValidationClear,
325                expected: None,
326            }],
327        }
328    }
329
330    #[test]
331    fn valid_task_round_trips_canonically() {
332        let task = task();
333        let first = task.to_canonical_json().unwrap();
334        let second = GlassTask::from_json(&first)
335            .unwrap()
336            .to_canonical_json()
337            .unwrap();
338        assert_eq!(first, second);
339        assert!(first.contains("form.fill"));
340        assert!(first.contains("Shipping"));
341    }
342
343    #[test]
344    fn authored_json_rejects_unknown_fields() {
345        let mut value = serde_json::to_value(task()).unwrap();
346        value["futureField"] = json!(true);
347        let error = GlassTask::from_json(&value.to_string()).unwrap_err();
348        assert_eq!(error.path, "$");
349    }
350
351    #[test]
352    fn validation_rejects_empty_scope_and_unbounded_limits() {
353        let mut invalid = task();
354        invalid.scope = TaskScope::default();
355        assert_eq!(invalid.validate().unwrap_err().path, "scope");
356        invalid = task();
357        invalid.limits.max_actions = 0;
358        assert_eq!(invalid.validate().unwrap_err().path, "limits.maxActions");
359        invalid = task();
360        invalid.limits.timeout_ms = MAX_TIMEOUT_MS + 1;
361        assert_eq!(invalid.validate().unwrap_err().path, "limits.timeoutMs");
362    }
363
364    #[test]
365    fn form_fill_requires_inputs_and_entity_names_require_kinds() {
366        let mut invalid = task();
367        invalid.inputs.clear();
368        assert_eq!(invalid.validate().unwrap_err().path, "inputs");
369        invalid = task();
370        invalid.scope.entity_name = Some("Email".into());
371        invalid.scope.entity_kind = None;
372        assert_eq!(invalid.validate().unwrap_err().path, "scope.entityKind");
373    }
374}