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