Skip to main content

glass/
protocol.rs

1//! Transport-neutral Glass request and response envelopes.
2//!
3//! MCP keeps its JSON-RPC framing, but daemon clients and embedded callers use
4//! these envelopes for the operation payload. The envelope is intentionally
5//! small: transport-specific framing, streaming, and authentication remain
6//! outside this contract.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Version of the canonical Glass operation envelope.
12pub const GLASS_PROTOCOL_VERSION: u32 = 1;
13const MAX_ID_BYTES: usize = 128;
14const MAX_OPERATION_BYTES: usize = 96;
15const MAX_ERROR_CODE_BYTES: usize = 64;
16const MAX_MESSAGE_BYTES: usize = 512;
17const MAX_DEADLINE_MS: u64 = 15 * 60 * 1_000;
18
19/// Canonical transport operation for browser-free Web IR validation.
20pub const WEB_IR_VALIDATE_OPERATION: &str = "webIr.validate";
21/// Canonical transport operation for browser-free Web IR inspection.
22pub const WEB_IR_INSPECT_OPERATION: &str = "webIr.inspect";
23/// Canonical transport operation for browser-free Web IR revision diffs.
24pub const WEB_IR_DIFF_OPERATION: &str = "webIr.diff";
25/// Canonical transport operation for browser-free Web IR continuity checks.
26pub const WEB_IR_CONTINUITY_OPERATION: &str = "webIr.continuity";
27
28/// Canonical transport operation for browser-free Task Protocol compilation.
29pub const TASK_COMPILE_OPERATION: &str = "task.compile";
30/// Canonical transport operation for browser-free Task Protocol validation.
31pub const TASK_VALIDATE_OPERATION: &str = "task.validate";
32
33/// Typed payload carried by a `task.compile` request.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct TaskCompilePayload {
37    pub task: crate::task_protocol::GlassTask,
38}
39
40impl TaskCompilePayload {
41    /// Validate the authored task before compiler dispatch.
42    pub fn validate(&self) -> Result<(), ProtocolError> {
43        self.task
44            .validate()
45            .map_err(|error| ProtocolError::TaskCompilation(error.into()))
46    }
47}
48
49/// Typed payload carried by a `task.validate` request.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct TaskValidationPayload {
53    pub task: crate::task_protocol::GlassTask,
54}
55
56impl TaskValidationPayload {
57    /// Validate the authored task before validation dispatch.
58    pub fn validate(&self) -> Result<(), ProtocolError> {
59        self.task.validate().map_err(ProtocolError::TaskValidation)
60    }
61}
62
63/// Typed payload carried by a `webIr.validate` or `webIr.inspect` request.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct WebIrDraftPayload {
67    pub draft: crate::web_ir::GlassWebIrDraft,
68}
69
70impl WebIrDraftPayload {
71    /// Validate the draft graph before browser-free dispatch.
72    pub fn validate(&self) -> Result<(), ProtocolError> {
73        self.draft
74            .validate()
75            .map_err(ProtocolError::WebIrValidation)
76    }
77}
78
79/// Typed payload carried by a `webIr.diff` request.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct WebIrDiffPayload {
83    pub before: crate::web_ir::GlassWebIrDraft,
84    pub after: crate::web_ir::GlassWebIrDraft,
85}
86
87impl WebIrDiffPayload {
88    /// Validate both draft graphs before diff dispatch.
89    pub fn validate(&self) -> Result<(), ProtocolError> {
90        self.before
91            .validate()
92            .map_err(ProtocolError::WebIrValidation)?;
93        self.after
94            .validate()
95            .map_err(ProtocolError::WebIrValidation)?;
96        self.before
97            .validate_revision_transition(&self.after)
98            .map_err(ProtocolError::WebIrValidation)?;
99        Ok(())
100    }
101}
102
103/// Typed payload carried by a `webIr.continuity` request.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields)]
106pub struct WebIrContinuityPayload {
107    pub before: crate::web_ir::GlassWebIrDraft,
108    pub after: crate::web_ir::GlassWebIrDraft,
109    pub entity_id: String,
110}
111
112impl WebIrContinuityPayload {
113    /// Validate both draft graphs and the bounded source entity ID.
114    pub fn validate(&self) -> Result<(), ProtocolError> {
115        self.before
116            .validate()
117            .map_err(ProtocolError::WebIrValidation)?;
118        self.after
119            .validate()
120            .map_err(ProtocolError::WebIrValidation)?;
121        self.before
122            .validate_revision_transition(&self.after)
123            .map_err(ProtocolError::WebIrValidation)?;
124        validate_identifier(&self.entity_id, "entityId")
125    }
126}
127
128/// Typed successful result for a `task.compile` operation.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub struct TaskCompileResult {
132    pub plan: crate::task_compiler::TaskExecutionPlan,
133}
134
135/// Typed successful result for browser-free Task Protocol validation.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct TaskValidationResult {
139    pub valid: bool,
140    pub schema_version: u32,
141    pub task: crate::task_protocol::TaskKind,
142}
143
144/// Bounded successful result for browser-free Web IR validation.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct WebIrValidationResult {
148    pub valid: bool,
149    pub schema_version: u32,
150    pub revision: u64,
151}
152
153impl WebIrValidationResult {
154    /// Build the validation result after a draft has passed graph validation.
155    pub fn from_draft(draft: &crate::web_ir::GlassWebIrDraft) -> Self {
156        Self {
157            valid: true,
158            schema_version: draft.schema_version,
159            revision: draft.revision,
160        }
161    }
162}
163
164/// Bounded summary result for browser-free Web IR inspection.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct WebIrInspectionResult {
168    pub schema_version: u32,
169    pub revision: u64,
170    pub entity_count: usize,
171    pub relationship_count: usize,
172    pub coverage: crate::extraction::EvidenceCoverage,
173    pub truncated: bool,
174    pub opaque_regions: u32,
175    pub diagnostic_count: usize,
176    pub relationship_hint_diagnostic_count: usize,
177}
178
179impl WebIrInspectionResult {
180    /// Build a bounded summary after a draft has passed graph validation.
181    pub fn from_draft(draft: &crate::web_ir::GlassWebIrDraft) -> Self {
182        Self {
183            schema_version: draft.schema_version,
184            revision: draft.revision,
185            entity_count: draft.entities.len(),
186            relationship_count: draft.relationships.len(),
187            coverage: draft.coverage.clone(),
188            truncated: draft.limits.truncated,
189            opaque_regions: draft.coverage.opaque_regions,
190            diagnostic_count: draft.diagnostics.len(),
191            relationship_hint_diagnostic_count: draft.relationship_hint_diagnostics.len(),
192        }
193    }
194}
195
196/// Bounded summary result for a browser-free Web IR revision diff.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase", deny_unknown_fields)]
199pub struct WebIrDiffResult {
200    pub schema_version: u32,
201    pub from_revision: u64,
202    pub to_revision: u64,
203    pub entity_added_count: usize,
204    pub entity_removed_count: usize,
205    pub entity_changed_count: usize,
206    pub relationship_added_count: usize,
207    pub relationship_removed_count: usize,
208    pub coverage_changed: bool,
209    pub limits_changed: bool,
210    pub diagnostics_changed: bool,
211    pub relationship_hint_diagnostics_changed: bool,
212}
213
214impl WebIrDiffResult {
215    /// Build a bounded summary without exposing entity or page content.
216    pub fn from_diff(diff: &crate::web_ir::GlassWebIrDiff) -> Self {
217        Self {
218            schema_version: diff.schema_version,
219            from_revision: diff.from_revision,
220            to_revision: diff.to_revision,
221            entity_added_count: diff
222                .entity_changes
223                .iter()
224                .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Added)
225                .count(),
226            entity_removed_count: diff
227                .entity_changes
228                .iter()
229                .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Removed)
230                .count(),
231            entity_changed_count: diff
232                .entity_changes
233                .iter()
234                .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Changed)
235                .count(),
236            relationship_added_count: diff
237                .relationship_changes
238                .iter()
239                .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Added)
240                .count(),
241            relationship_removed_count: diff
242                .relationship_changes
243                .iter()
244                .filter(|change| change.kind == crate::web_ir::DraftChangeKind::Removed)
245                .count(),
246            coverage_changed: diff.coverage_changed,
247            limits_changed: diff.limits_changed,
248            diagnostics_changed: diff.diagnostics_changed,
249            relationship_hint_diagnostics_changed: diff.relationship_hint_diagnostics_changed,
250        }
251    }
252}
253
254/// Browser-free Web IR continuity classification for one entity.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase", deny_unknown_fields)]
257pub struct WebIrContinuityResult {
258    pub requested_id: String,
259    pub status: crate::web_ir::DraftEntityContinuityStatus,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub current_id: Option<String>,
262    pub reason: String,
263}
264
265impl From<crate::web_ir::DraftEntityContinuity> for WebIrContinuityResult {
266    fn from(continuity: crate::web_ir::DraftEntityContinuity) -> Self {
267        Self {
268            requested_id: continuity.requested_id,
269            status: continuity.status,
270            current_id: continuity.current_id,
271            reason: continuity.reason,
272        }
273    }
274}
275
276impl TaskCompileResult {
277    /// Validate the embedded deterministic execution plan.
278    pub fn validate(&self) -> Result<(), ProtocolError> {
279        self.plan.validate().map_err(ProtocolError::TaskCompilation)
280    }
281}
282
283/// A request-independent mutation lease reference.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct MutationLeaseRef {
287    pub session_id: String,
288    pub token: String,
289}
290
291/// Canonical operation request shared by supported transports.
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase", deny_unknown_fields)]
294pub struct GlassRequest {
295    pub protocol_version: u32,
296    pub request_id: String,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub correlation_id: Option<String>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub session_id: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub mutation_lease: Option<MutationLeaseRef>,
303    pub operation: String,
304    pub payload: Value,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub deadline_ms: Option<u64>,
307}
308
309impl GlassRequest {
310    /// Validate protocol version, identifiers, operation bounds, and deadline.
311    pub fn validate(&self) -> Result<(), ProtocolError> {
312        if self.protocol_version != GLASS_PROTOCOL_VERSION {
313            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
314        }
315        validate_identifier(&self.request_id, "requestId")?;
316        if let Some(correlation_id) = &self.correlation_id {
317            validate_identifier(correlation_id, "correlationId")?;
318        }
319        if let Some(session_id) = &self.session_id {
320            validate_identifier(session_id, "sessionId")?;
321        }
322        if let Some(lease) = &self.mutation_lease {
323            validate_identifier(&lease.session_id, "mutationLease.sessionId")?;
324            validate_identifier(&lease.token, "mutationLease.token")?;
325        }
326        if self.operation.is_empty() || self.operation.len() > MAX_OPERATION_BYTES {
327            return Err(ProtocolError::InvalidField(
328                "operation must be a bounded non-empty string".into(),
329            ));
330        }
331        if self.operation.chars().any(char::is_whitespace) {
332            return Err(ProtocolError::InvalidField(
333                "operation must not contain whitespace".into(),
334            ));
335        }
336        if let Some(deadline_ms) = self.deadline_ms
337            && !(1..=MAX_DEADLINE_MS).contains(&deadline_ms)
338        {
339            return Err(ProtocolError::InvalidField(format!(
340                "deadlineMs must be 1..={MAX_DEADLINE_MS}"
341            )));
342        }
343        Ok(())
344    }
345
346    /// Decode and validate a typed `task.compile` payload.
347    pub fn decode_task_compile(&self) -> Result<TaskCompilePayload, ProtocolError> {
348        self.validate()?;
349        if self.operation != TASK_COMPILE_OPERATION {
350            return Err(ProtocolError::InvalidField(format!(
351                "expected operation {TASK_COMPILE_OPERATION}"
352            )));
353        }
354        let payload: TaskCompilePayload =
355            serde_json::from_value(self.payload.clone()).map_err(|error| {
356                ProtocolError::InvalidField(format!("task.compile payload: {error}"))
357            })?;
358        payload.validate()?;
359        Ok(payload)
360    }
361
362    /// Decode and validate a typed `task.validate` payload.
363    pub fn decode_task_validate(&self) -> Result<TaskValidationPayload, ProtocolError> {
364        self.validate()?;
365        if self.operation != TASK_VALIDATE_OPERATION {
366            return Err(ProtocolError::InvalidField(format!(
367                "expected operation {TASK_VALIDATE_OPERATION}"
368            )));
369        }
370        let payload: TaskValidationPayload =
371            serde_json::from_value(self.payload.clone()).map_err(|error| {
372                ProtocolError::InvalidField(format!("task.validate payload: {error}"))
373            })?;
374        payload.validate()?;
375        Ok(payload)
376    }
377
378    /// Decode and validate a typed `webIr.validate` payload.
379    pub fn decode_web_ir_validate(&self) -> Result<WebIrDraftPayload, ProtocolError> {
380        self.decode_web_ir_draft(WEB_IR_VALIDATE_OPERATION)
381    }
382
383    /// Decode and validate a typed `webIr.inspect` payload.
384    pub fn decode_web_ir_inspect(&self) -> Result<WebIrDraftPayload, ProtocolError> {
385        self.decode_web_ir_draft(WEB_IR_INSPECT_OPERATION)
386    }
387
388    fn decode_web_ir_draft(&self, operation: &str) -> Result<WebIrDraftPayload, ProtocolError> {
389        self.validate()?;
390        if self.operation != operation {
391            return Err(ProtocolError::InvalidField(format!(
392                "expected operation {operation}"
393            )));
394        }
395        let payload: WebIrDraftPayload =
396            serde_json::from_value(self.payload.clone()).map_err(|error| {
397                ProtocolError::InvalidField(format!("{operation} payload: {error}"))
398            })?;
399        payload.validate()?;
400        Ok(payload)
401    }
402
403    /// Decode and validate a typed `webIr.diff` payload.
404    pub fn decode_web_ir_diff(&self) -> Result<WebIrDiffPayload, ProtocolError> {
405        self.validate()?;
406        if self.operation != WEB_IR_DIFF_OPERATION {
407            return Err(ProtocolError::InvalidField(format!(
408                "expected operation {WEB_IR_DIFF_OPERATION}"
409            )));
410        }
411        let payload: WebIrDiffPayload = serde_json::from_value(self.payload.clone())
412            .map_err(|error| ProtocolError::InvalidField(format!("webIr.diff payload: {error}")))?;
413        payload.validate()?;
414        Ok(payload)
415    }
416
417    /// Decode and validate a typed `webIr.continuity` payload.
418    pub fn decode_web_ir_continuity(&self) -> Result<WebIrContinuityPayload, ProtocolError> {
419        self.validate()?;
420        if self.operation != WEB_IR_CONTINUITY_OPERATION {
421            return Err(ProtocolError::InvalidField(format!(
422                "expected operation {WEB_IR_CONTINUITY_OPERATION}"
423            )));
424        }
425        let payload: WebIrContinuityPayload = serde_json::from_value(self.payload.clone())
426            .map_err(|error| {
427                ProtocolError::InvalidField(format!("webIr.continuity payload: {error}"))
428            })?;
429        payload.validate()?;
430        Ok(payload)
431    }
432}
433
434/// Decode and compile a `task.compile` request without browser access.
435pub fn compile_task_request(
436    request: &GlassRequest,
437) -> Result<crate::task_compiler::TaskExecutionPlan, ProtocolError> {
438    let payload = request.decode_task_compile()?;
439    crate::task_compiler::compile_task(&payload.task).map_err(ProtocolError::TaskCompilation)
440}
441
442/// Decode and compile a `task.compile` request into a typed response payload.
443pub fn compile_task_result(request: &GlassRequest) -> Result<TaskCompileResult, ProtocolError> {
444    Ok(TaskCompileResult {
445        plan: compile_task_request(request)?,
446    })
447}
448
449/// Validate a `task.validate` request into a typed response payload.
450pub fn validate_task_result(request: &GlassRequest) -> Result<TaskValidationResult, ProtocolError> {
451    let payload = request.decode_task_validate()?;
452    Ok(TaskValidationResult {
453        valid: true,
454        schema_version: payload.task.schema_version,
455        task: payload.task.task,
456    })
457}
458
459/// Validate a Web IR draft from a canonical request.
460pub fn web_ir_validate_result(
461    request: &GlassRequest,
462) -> Result<WebIrValidationResult, ProtocolError> {
463    let payload = request.decode_web_ir_validate()?;
464    Ok(WebIrValidationResult::from_draft(&payload.draft))
465}
466
467/// Inspect a Web IR draft from a canonical request.
468pub fn web_ir_inspect_result(
469    request: &GlassRequest,
470) -> Result<WebIrInspectionResult, ProtocolError> {
471    let payload = request.decode_web_ir_inspect()?;
472    Ok(WebIrInspectionResult::from_draft(&payload.draft))
473}
474
475/// Compute a bounded Web IR diff from a canonical request.
476pub fn web_ir_diff_result(request: &GlassRequest) -> Result<WebIrDiffResult, ProtocolError> {
477    let payload = request.decode_web_ir_diff()?;
478    let diff = payload
479        .before
480        .diff(&payload.after)
481        .map_err(ProtocolError::WebIrValidation)?;
482    Ok(WebIrDiffResult::from_diff(&diff))
483}
484
485/// Classify one Web IR entity from a canonical request.
486pub fn web_ir_continuity_result(
487    request: &GlassRequest,
488) -> Result<WebIrContinuityResult, ProtocolError> {
489    let payload = request.decode_web_ir_continuity()?;
490    let continuity = payload
491        .before
492        .classify_entity_continuity(&payload.after, &payload.entity_id)
493        .map_err(ProtocolError::WebIrValidation)?;
494    Ok(WebIrContinuityResult::from(continuity))
495}
496
497/// Canonical operation response shared by supported transports.
498#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct GlassResponse {
501    pub protocol_version: u32,
502    pub request_id: String,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub correlation_id: Option<String>,
505    pub ok: bool,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub result: Option<Value>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub error: Option<GlassError>,
510}
511
512impl GlassResponse {
513    /// Validate envelope identity and the mutually exclusive result/error form.
514    pub fn validate(&self) -> Result<(), ProtocolError> {
515        if self.protocol_version != GLASS_PROTOCOL_VERSION {
516            return Err(ProtocolError::UnsupportedVersion(self.protocol_version));
517        }
518        validate_identifier(&self.request_id, "requestId")?;
519        if let Some(correlation_id) = &self.correlation_id {
520            validate_identifier(correlation_id, "correlationId")?;
521        }
522        match (self.ok, self.result.is_some(), self.error.is_some()) {
523            (true, true, false) | (false, false, true) => Ok(()),
524            _ => Err(ProtocolError::InvalidField(
525                "ok responses require result and error responses require error".into(),
526            )),
527        }
528    }
529
530    /// Decode and validate a successful typed `task.compile` result.
531    pub fn decode_task_compile_result(&self) -> Result<TaskCompileResult, ProtocolError> {
532        self.validate()?;
533        if !self.ok {
534            return Err(ProtocolError::InvalidField(
535                "task.compile result requires a successful response".into(),
536            ));
537        }
538        let value = self
539            .result
540            .clone()
541            .ok_or_else(|| ProtocolError::InvalidField("task.compile result is missing".into()))?;
542        let result: TaskCompileResult = serde_json::from_value(value).map_err(|error| {
543            ProtocolError::InvalidField(format!("task.compile result: {error}"))
544        })?;
545        result.validate()?;
546        Ok(result)
547    }
548
549    /// Decode and validate a successful typed `task.validate` result.
550    pub fn decode_task_validation_result(&self) -> Result<TaskValidationResult, ProtocolError> {
551        self.validate()?;
552        if !self.ok {
553            return Err(ProtocolError::InvalidField(
554                "task.validate result requires a successful response".into(),
555            ));
556        }
557        let value = self
558            .result
559            .clone()
560            .ok_or_else(|| ProtocolError::InvalidField("task.validate result is missing".into()))?;
561        serde_json::from_value(value)
562            .map_err(|error| ProtocolError::InvalidField(format!("task.validate result: {error}")))
563    }
564
565    /// Decode and validate a successful bounded `webIr.validate` result.
566    pub fn decode_web_ir_validation_result(&self) -> Result<WebIrValidationResult, ProtocolError> {
567        self.decode_web_ir_result("webIr.validate")
568    }
569
570    /// Decode and validate a successful bounded `webIr.inspect` result.
571    pub fn decode_web_ir_inspection_result(&self) -> Result<WebIrInspectionResult, ProtocolError> {
572        self.decode_web_ir_result("webIr.inspect")
573    }
574
575    fn decode_web_ir_result<T>(&self, operation: &str) -> Result<T, ProtocolError>
576    where
577        T: for<'de> Deserialize<'de>,
578    {
579        self.validate()?;
580        if !self.ok {
581            return Err(ProtocolError::InvalidField(format!(
582                "{operation} result requires a successful response"
583            )));
584        }
585        let value = self
586            .result
587            .clone()
588            .ok_or_else(|| ProtocolError::InvalidField(format!("{operation} result is missing")))?;
589        serde_json::from_value(value)
590            .map_err(|error| ProtocolError::InvalidField(format!("{operation} result: {error}")))
591    }
592
593    /// Decode and validate a successful bounded `webIr.diff` result.
594    pub fn decode_web_ir_diff_result(&self) -> Result<WebIrDiffResult, ProtocolError> {
595        self.validate()?;
596        if !self.ok {
597            return Err(ProtocolError::InvalidField(
598                "webIr.diff result requires a successful response".into(),
599            ));
600        }
601        let value = self
602            .result
603            .clone()
604            .ok_or_else(|| ProtocolError::InvalidField("webIr.diff result is missing".into()))?;
605        serde_json::from_value(value)
606            .map_err(|error| ProtocolError::InvalidField(format!("webIr.diff result: {error}")))
607    }
608
609    /// Decode and validate a successful Web IR continuity result.
610    pub fn decode_web_ir_continuity_result(&self) -> Result<WebIrContinuityResult, ProtocolError> {
611        self.validate()?;
612        if !self.ok {
613            return Err(ProtocolError::InvalidField(
614                "webIr.continuity result requires a successful response".into(),
615            ));
616        }
617        let value = self.result.clone().ok_or_else(|| {
618            ProtocolError::InvalidField("webIr.continuity result is missing".into())
619        })?;
620        serde_json::from_value(value).map_err(|error| {
621            ProtocolError::InvalidField(format!("webIr.continuity result: {error}"))
622        })
623    }
624}
625
626/// Phase in which a public operation stopped.
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
628#[serde(rename_all = "camelCase")]
629pub enum ErrorPhase {
630    #[default]
631    Preflight,
632    Dispatch,
633    PostDispatch,
634    Verification,
635    Reconciliation,
636}
637
638/// Stable retry classification for agent recovery.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
640#[serde(rename_all = "camelCase")]
641pub enum RetryClassification {
642    SafeImmediate,
643    #[default]
644    SafeAfterReobserve,
645    SafeAfterReconcile,
646    UnsafeUntilReconciled,
647    RequiresUserDecision,
648    NotRetryable,
649    Unknown,
650}
651
652/// Bounded recovery guidance attached to every canonical failure.
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[serde(rename_all = "camelCase")]
655pub struct RetryGuidance {
656    pub classification: RetryClassification,
657    pub recommended_operation: String,
658}
659
660impl Default for RetryGuidance {
661    fn default() -> Self {
662        Self {
663            classification: RetryClassification::SafeAfterReobserve,
664            recommended_operation: "inspect_page".into(),
665        }
666    }
667}
668
669/// Structured failure that can be carried across transports without parsing text.
670#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
671#[serde(rename_all = "camelCase")]
672pub struct GlassError {
673    pub code: String,
674    #[serde(default)]
675    pub phase: ErrorPhase,
676    pub message: String,
677    #[serde(default)]
678    pub mutation_possible: bool,
679    #[serde(default)]
680    pub retry: RetryGuidance,
681    /// Kept as a tolerated compatibility field for pre-0.2.2 clients.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub retryable: Option<bool>,
684    #[serde(skip_serializing_if = "Option::is_none")]
685    pub details: Option<Value>,
686}
687
688impl GlassError {
689    /// Validate bounded, non-empty diagnostic fields.
690    pub fn validate(&self) -> Result<(), ProtocolError> {
691        if self.code.is_empty() || self.code.len() > MAX_ERROR_CODE_BYTES {
692            return Err(ProtocolError::InvalidField(
693                "error code must be a bounded non-empty string".into(),
694            ));
695        }
696        if self.message.is_empty() || self.message.len() > MAX_MESSAGE_BYTES {
697            return Err(ProtocolError::InvalidField(
698                "error message must be a bounded non-empty string".into(),
699            ));
700        }
701        validate_identifier(
702            &self.retry.recommended_operation,
703            "retry.recommendedOperation",
704        )?;
705        Ok(())
706    }
707}
708
709/// Validation failure for the canonical protocol envelope.
710#[derive(Debug, Clone, PartialEq, Eq)]
711pub enum ProtocolError {
712    UnsupportedVersion(u32),
713    InvalidField(String),
714    TaskValidation(crate::task_protocol::TaskProtocolError),
715    TaskCompilation(crate::task_compiler::TaskCompilationError),
716    WebIrValidation(crate::web_ir::WebIrValidationError),
717}
718
719impl std::fmt::Display for ProtocolError {
720    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721        match self {
722            Self::UnsupportedVersion(version) => {
723                write!(formatter, "unsupported Glass protocol version {version}")
724            }
725            Self::InvalidField(detail) => formatter.write_str(detail),
726            Self::TaskValidation(error) => error.fmt(formatter),
727            Self::TaskCompilation(error) => error.fmt(formatter),
728            Self::WebIrValidation(error) => error.fmt(formatter),
729        }
730    }
731}
732
733impl std::error::Error for ProtocolError {}
734
735fn validate_identifier(value: &str, field: &str) -> Result<(), ProtocolError> {
736    if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_whitespace) {
737        return Err(ProtocolError::InvalidField(format!(
738            "{field} must be a bounded non-whitespace identifier"
739        )));
740    }
741    Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    fn request() -> GlassRequest {
749        GlassRequest {
750            protocol_version: GLASS_PROTOCOL_VERSION,
751            request_id: "request-1".into(),
752            correlation_id: Some("run-1".into()),
753            session_id: Some("session-1".into()),
754            mutation_lease: Some(MutationLeaseRef {
755                session_id: "session-1".into(),
756                token: "lease-1".into(),
757            }),
758            operation: "browser.observe".into(),
759            payload: serde_json::json!({"level": "interactive"}),
760            deadline_ms: Some(5_000),
761        }
762    }
763
764    fn web_ir_draft(revision: u64, name: &str) -> crate::web_ir::GlassWebIrDraft {
765        serde_json::from_value(serde_json::json!({
766            "schemaVersion": 1,
767            "revision": revision,
768            "entities": [
769                {
770                    "id": "page",
771                    "kind": "page",
772                    "quality": "confirmed",
773                    "evidenceSources": []
774                },
775                {
776                    "id": "field-1",
777                    "kind": "field",
778                    "role": "textbox",
779                    "name": name,
780                    "quality": "strong",
781                    "evidenceSources": ["dom"]
782                }
783            ],
784            "relationships": [
785                {"from": "page", "to": "field-1", "kind": "contains"}
786            ],
787            "coverage": {
788                "structural": "strong",
789                "semantic": "strong",
790                "interactiveEntitiesObserved": 1,
791                "opaqueRegions": 0,
792                "reasons": []
793            },
794            "limits": {
795                "truncated": false,
796                "omittedFacts": 0,
797                "textBytes": 0,
798                "missingSources": []
799            }
800        }))
801        .unwrap()
802    }
803
804    #[test]
805    fn request_round_trips_and_validates() {
806        let request = request();
807        request.validate().unwrap();
808        let value = serde_json::to_value(&request).unwrap();
809        assert_eq!(value["protocolVersion"], 1);
810        assert_eq!(value["mutationLease"]["sessionId"], "session-1");
811        let decoded: GlassRequest = serde_json::from_value(value).unwrap();
812        assert_eq!(decoded, request);
813    }
814
815    #[test]
816    fn response_requires_exactly_one_outcome() {
817        let response = GlassResponse {
818            protocol_version: GLASS_PROTOCOL_VERSION,
819            request_id: "request-1".into(),
820            correlation_id: None,
821            ok: false,
822            result: None,
823            error: Some(GlassError {
824                code: "target.stale".into(),
825                phase: ErrorPhase::Preflight,
826                message: "a mutation lease is required".into(),
827                mutation_possible: false,
828                retry: RetryGuidance {
829                    classification: RetryClassification::SafeAfterReobserve,
830                    recommended_operation: "inspect_page".into(),
831                },
832                retryable: Some(true),
833                details: None,
834            }),
835        };
836        response.validate().unwrap();
837        let mut invalid = response.clone();
838        invalid.ok = true;
839        assert!(invalid.validate().is_err());
840    }
841
842    #[test]
843    fn web_ir_revision_operations_round_trip_with_bounded_results() {
844        let before = web_ir_draft(7, "Email");
845        let after = web_ir_draft(8, "Email address");
846        let diff_request = GlassRequest {
847            protocol_version: GLASS_PROTOCOL_VERSION,
848            request_id: "diff-1".into(),
849            correlation_id: None,
850            session_id: None,
851            mutation_lease: None,
852            operation: WEB_IR_DIFF_OPERATION.into(),
853            payload: serde_json::to_value(WebIrDiffPayload {
854                before: before.clone(),
855                after: after.clone(),
856            })
857            .unwrap(),
858            deadline_ms: None,
859        };
860        let diff = web_ir_diff_result(&diff_request).unwrap();
861        assert_eq!(diff.from_revision, 7);
862        assert_eq!(diff.to_revision, 8);
863        assert_eq!(diff.entity_changed_count, 1);
864        assert_eq!(diff_request.decode_web_ir_diff().unwrap().before, before);
865
866        let continuity_request = GlassRequest {
867            protocol_version: GLASS_PROTOCOL_VERSION,
868            request_id: "continuity-1".into(),
869            correlation_id: None,
870            session_id: None,
871            mutation_lease: None,
872            operation: WEB_IR_CONTINUITY_OPERATION.into(),
873            payload: serde_json::to_value(WebIrContinuityPayload {
874                before,
875                after,
876                entity_id: "field-1".into(),
877            })
878            .unwrap(),
879            deadline_ms: None,
880        };
881        let continuity = web_ir_continuity_result(&continuity_request).unwrap();
882        assert_eq!(
883            continuity.status,
884            crate::web_ir::DraftEntityContinuityStatus::Changed
885        );
886        let response = GlassResponse {
887            protocol_version: GLASS_PROTOCOL_VERSION,
888            request_id: continuity_request.request_id.clone(),
889            correlation_id: None,
890            ok: true,
891            result: Some(serde_json::to_value(&continuity).unwrap()),
892            error: None,
893        };
894        assert_eq!(
895            response.decode_web_ir_continuity_result().unwrap(),
896            continuity
897        );
898    }
899
900    #[test]
901    fn web_ir_inspect_and_validate_operations_round_trip() {
902        let draft = web_ir_draft(7, "Email");
903        let validate_request = GlassRequest {
904            protocol_version: GLASS_PROTOCOL_VERSION,
905            request_id: "validate-1".into(),
906            correlation_id: None,
907            session_id: None,
908            mutation_lease: None,
909            operation: WEB_IR_VALIDATE_OPERATION.into(),
910            payload: serde_json::json!({"draft": draft.clone()}),
911            deadline_ms: None,
912        };
913        let validation = web_ir_validate_result(&validate_request).unwrap();
914        assert!(validation.valid);
915        assert_eq!(
916            validate_request.decode_web_ir_validate().unwrap().draft,
917            draft
918        );
919        let validation_response = GlassResponse {
920            protocol_version: GLASS_PROTOCOL_VERSION,
921            request_id: "validate-1".into(),
922            correlation_id: None,
923            ok: true,
924            result: Some(serde_json::to_value(&validation).unwrap()),
925            error: None,
926        };
927        assert_eq!(
928            validation_response
929                .decode_web_ir_validation_result()
930                .unwrap(),
931            validation
932        );
933
934        let inspect_request = GlassRequest {
935            operation: WEB_IR_INSPECT_OPERATION.into(),
936            request_id: "inspect-1".into(),
937            payload: serde_json::json!({"draft": draft}),
938            ..validate_request
939        };
940        let inspection = web_ir_inspect_result(&inspect_request).unwrap();
941        let inspection_response = GlassResponse {
942            protocol_version: GLASS_PROTOCOL_VERSION,
943            request_id: "inspect-1".into(),
944            correlation_id: None,
945            ok: true,
946            result: Some(serde_json::to_value(&inspection).unwrap()),
947            error: None,
948        };
949        assert_eq!(
950            inspection_response
951                .decode_web_ir_inspection_result()
952                .unwrap(),
953            inspection
954        );
955    }
956
957    #[test]
958    fn bounds_and_unknown_fields_fail_closed() {
959        let mut request = request();
960        request.operation = "bad operation".into();
961        assert!(request.validate().is_err());
962        let unknown = serde_json::json!({
963            "protocolVersion": 1,
964            "requestId": "request-1",
965            "operation": "browser.observe",
966            "payload": {},
967            "future": true
968        });
969        assert!(serde_json::from_value::<GlassRequest>(unknown).is_err());
970    }
971
972    #[test]
973    fn task_validate_boundary_decodes_without_compiling() {
974        let task = serde_json::json!({
975            "schemaVersion": 1,
976            "task": "region.extract",
977            "scope": {"regionName": "Checkout"},
978            "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
979            "risk": "readOnly"
980        });
981        let request = GlassRequest {
982            protocol_version: GLASS_PROTOCOL_VERSION,
983            request_id: "validate-task-1".into(),
984            correlation_id: None,
985            session_id: None,
986            mutation_lease: None,
987            operation: TASK_VALIDATE_OPERATION.into(),
988            payload: serde_json::json!({"task": task}),
989            deadline_ms: None,
990        };
991        let result = validate_task_result(&request).unwrap();
992        assert_eq!(
993            result,
994            TaskValidationResult {
995                valid: true,
996                schema_version: 1,
997                task: crate::task_protocol::TaskKind::RegionExtract,
998            }
999        );
1000        let response = GlassResponse {
1001            protocol_version: GLASS_PROTOCOL_VERSION,
1002            request_id: request.request_id.clone(),
1003            correlation_id: None,
1004            ok: true,
1005            result: Some(serde_json::to_value(&result).unwrap()),
1006            error: None,
1007        };
1008        assert_eq!(response.decode_task_validation_result().unwrap(), result);
1009        assert_eq!(
1010            request.decode_task_validate().unwrap().task.task,
1011            crate::task_protocol::TaskKind::RegionExtract
1012        );
1013    }
1014
1015    #[test]
1016    fn task_compile_boundary_decodes_and_compiles_without_browser_state() {
1017        let task = serde_json::json!({
1018            "schemaVersion": 1,
1019            "task": "region.extract",
1020            "scope": {"regionName": "Checkout"},
1021            "limits": {"maxActions": 8, "timeoutMs": 5000, "maxItems": 32},
1022            "risk": "readOnly"
1023        });
1024        let request = GlassRequest {
1025            protocol_version: GLASS_PROTOCOL_VERSION,
1026            request_id: "compile-1".into(),
1027            correlation_id: None,
1028            session_id: None,
1029            mutation_lease: None,
1030            operation: TASK_COMPILE_OPERATION.into(),
1031            payload: serde_json::json!({"task": task}),
1032            deadline_ms: None,
1033        };
1034        let plan = compile_task_request(&request).unwrap();
1035        assert_eq!(plan.task, crate::task_protocol::TaskKind::RegionExtract);
1036        assert_eq!(plan.scope.region_name.as_deref(), Some("Checkout"));
1037        assert_eq!(plan.limits.max_actions, 8);
1038        assert_eq!(
1039            plan.revision,
1040            crate::task_protocol::TaskRevisionPolicy::Exact
1041        );
1042
1043        let mut wrong_operation = request.clone();
1044        wrong_operation.operation = "browser.observe".into();
1045        assert!(wrong_operation.decode_task_compile().is_err());
1046
1047        let mut unknown = request.clone();
1048        unknown.payload["futureField"] = true.into();
1049        assert!(unknown.decode_task_compile().is_err());
1050
1051        let mut invalid = request;
1052        invalid.payload["task"]["task"] = "form.fill".into();
1053        assert!(compile_task_request(&invalid).is_err());
1054    }
1055
1056    #[test]
1057    fn task_compile_result_round_trips_through_success_response() {
1058        let request = GlassRequest {
1059            protocol_version: GLASS_PROTOCOL_VERSION,
1060            request_id: "compile-2".into(),
1061            correlation_id: None,
1062            session_id: None,
1063            mutation_lease: None,
1064            operation: TASK_COMPILE_OPERATION.into(),
1065            payload: serde_json::json!({
1066                "task": {
1067                    "schemaVersion": 1,
1068                    "task": "field.read",
1069                    "scope": {"entityKind": "field", "entityName": "Email"},
1070                    "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 1},
1071                    "risk": "readOnly"
1072                }
1073            }),
1074            deadline_ms: None,
1075        };
1076        let result = compile_task_result(&request).unwrap();
1077        let response = GlassResponse {
1078            protocol_version: GLASS_PROTOCOL_VERSION,
1079            request_id: request.request_id.clone(),
1080            correlation_id: None,
1081            ok: true,
1082            result: Some(serde_json::to_value(&result).unwrap()),
1083            error: None,
1084        };
1085        assert_eq!(response.decode_task_compile_result().unwrap(), result);
1086
1087        let mut unknown = response.clone();
1088        unknown.result.as_mut().unwrap()["futureField"] = true.into();
1089        assert!(unknown.decode_task_compile_result().is_err());
1090
1091        let mut failure = response;
1092        failure.ok = false;
1093        failure.result = None;
1094        failure.error = Some(GlassError {
1095            code: "task.invalid".into(),
1096            phase: ErrorPhase::Preflight,
1097            message: "invalid task".into(),
1098            mutation_possible: false,
1099            retry: RetryGuidance::default(),
1100            retryable: None,
1101            details: None,
1102        });
1103        assert!(failure.decode_task_compile_result().is_err());
1104    }
1105
1106    #[test]
1107    fn additive_response_fields_are_tolerated() {
1108        let response: GlassResponse = serde_json::from_value(serde_json::json!({
1109            "protocolVersion": 1,
1110            "requestId": "request-1",
1111            "ok": false,
1112            "error": {
1113                "code": "target.stale",
1114                "message": "stale",
1115                "retryable": true,
1116                "future": "ignored"
1117            },
1118            "future": true
1119        }))
1120        .unwrap();
1121        assert_eq!(
1122            response.error.unwrap().retry.classification,
1123            RetryClassification::SafeAfterReobserve
1124        );
1125    }
1126}