Skip to main content

bobby_browser_client/
interface.rs

1//! Interface versioning, correlation, errors, and operation authority.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::{
9    ArtifactId, Capability, CapabilitySet, CommandId, CommandOutcome, ErrorLayer, PrincipalId,
10};
11
12/// Value of the `x-interface-version` header for this release.
13pub const CURRENT_INTERFACE_VERSION: &str = "2026-08-19";
14
15/// Marker for the sole supported interface version ([`CURRENT_INTERFACE_VERSION`]).
16#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct InterfaceVersion;
19
20impl InterfaceVersion {
21    pub const CURRENT: Self = Self;
22}
23
24impl TryFrom<&str> for InterfaceVersion {
25    type Error = InterfaceValidationError;
26
27    fn try_from(value: &str) -> Result<Self, Self::Error> {
28        if value == CURRENT_INTERFACE_VERSION {
29            Ok(Self)
30        } else {
31            Err(InterfaceValidationError::UnsupportedInterfaceVersion(
32                value.to_owned(),
33            ))
34        }
35    }
36}
37
38impl TryFrom<String> for InterfaceVersion {
39    type Error = InterfaceValidationError;
40
41    fn try_from(value: String) -> Result<Self, Self::Error> {
42        Self::try_from(value.as_str())
43    }
44}
45
46impl Serialize for InterfaceVersion {
47    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
48    where
49        S: Serializer,
50    {
51        serializer.serialize_str(CURRENT_INTERFACE_VERSION)
52    }
53}
54
55impl<'de> Deserialize<'de> for InterfaceVersion {
56    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        let value = String::deserialize(deserializer)?;
61        Self::try_from(value).map_err(serde::de::Error::custom)
62    }
63}
64
65/// Correlation id for a single request (`x-correlation-id`).
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
68#[serde(transparent)]
69pub struct CorrelationId(Uuid);
70
71impl CorrelationId {
72    pub fn new() -> Self {
73        Self(Uuid::new_v4())
74    }
75
76    pub fn from_uuid(value: Uuid) -> Self {
77        Self(value)
78    }
79
80    pub fn as_uuid(&self) -> Uuid {
81        self.0
82    }
83}
84
85impl Default for CorrelationId {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91/// Caller-provided key that identifies a repeated request (`idempotency-key`).
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct IdempotencyKey(String);
95
96impl IdempotencyKey {
97    pub fn as_str(&self) -> &str {
98        &self.0
99    }
100}
101
102impl TryFrom<&str> for IdempotencyKey {
103    type Error = InterfaceValidationError;
104
105    fn try_from(value: &str) -> Result<Self, Self::Error> {
106        if !(1..=128).contains(&value.len())
107            || !value
108                .as_bytes()
109                .iter()
110                .all(|byte| (0x20..=0x7e).contains(byte))
111        {
112            return Err(InterfaceValidationError::InvalidIdempotencyKey);
113        }
114        Ok(Self(value.to_owned()))
115    }
116}
117
118impl TryFrom<String> for IdempotencyKey {
119    type Error = InterfaceValidationError;
120
121    fn try_from(value: String) -> Result<Self, Self::Error> {
122        Self::try_from(value.as_str())
123    }
124}
125
126impl Serialize for IdempotencyKey {
127    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
128    where
129        S: Serializer,
130    {
131        serializer.serialize_str(&self.0)
132    }
133}
134
135impl<'de> Deserialize<'de> for IdempotencyKey {
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        let value = String::deserialize(deserializer)?;
141        Self::try_from(value).map_err(serde::de::Error::custom)
142    }
143}
144
145#[derive(
146    Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
147)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149#[serde(transparent)]
150pub struct EventCursor(pub u64);
151
152impl EventCursor {
153    pub const ZERO: Self = Self(0);
154}
155
156impl From<u64> for EventCursor {
157    fn from(value: u64) -> Self {
158        Self(value)
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164#[serde(rename_all = "camelCase")]
165pub struct RequestContext {
166    pub interface_version: InterfaceVersion,
167    pub correlation_id: CorrelationId,
168    pub principal_id: PrincipalId,
169    pub capabilities: CapabilitySet,
170    pub deadline: DateTime<Utc>,
171    pub idempotency_key: Option<IdempotencyKey>,
172}
173
174impl RequestContext {
175    pub fn new_for_test(
176        principal_id: PrincipalId,
177        capabilities: impl IntoIterator<Item = Capability>,
178        deadline: DateTime<Utc>,
179    ) -> Self {
180        Self {
181            interface_version: InterfaceVersion::CURRENT,
182            correlation_id: CorrelationId::new(),
183            principal_id,
184            capabilities: CapabilitySet::new(capabilities),
185            deadline,
186            idempotency_key: None,
187        }
188    }
189
190    /// Checks values whose validity depends on the instant an adapter dispatches the request.
191    pub fn validate_at(
192        &self,
193        dispatch_time: DateTime<Utc>,
194    ) -> Result<(), InterfaceValidationError> {
195        if self.deadline <= dispatch_time {
196            return Err(InterfaceValidationError::ExpiredDeadline);
197        }
198        Ok(())
199    }
200}
201
202/// Transport-neutral interface operations and their required capabilities.
203#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub enum InterfaceOperation {
206    RuntimeInfo,
207    CreateSession,
208    ReadSession,
209    DeleteSession,
210    OpenPage,
211    ReadPage,
212    ClosePage,
213    SubmitCommand,
214    CreateCheckpoint,
215    ReadCheckpoint,
216    RecoverWorkflow,
217    ReadArtifact,
218    ReadContext,
219    CaptureArtifact,
220    SubscribeEvents,
221    SubmitJob,
222    ReadJob,
223    CancelJob,
224    IssuePrincipal,
225    RevokePrincipal,
226}
227
228impl InterfaceOperation {
229    pub const ALL: [Self; 20] = [
230        Self::RuntimeInfo,
231        Self::CreateSession,
232        Self::ReadSession,
233        Self::DeleteSession,
234        Self::OpenPage,
235        Self::ReadPage,
236        Self::ClosePage,
237        Self::SubmitCommand,
238        Self::CreateCheckpoint,
239        Self::ReadCheckpoint,
240        Self::RecoverWorkflow,
241        Self::ReadArtifact,
242        Self::ReadContext,
243        Self::CaptureArtifact,
244        Self::SubscribeEvents,
245        Self::SubmitJob,
246        Self::ReadJob,
247        Self::CancelJob,
248        Self::IssuePrincipal,
249        Self::RevokePrincipal,
250    ];
251
252    pub const fn as_str(self) -> &'static str {
253        match self {
254            Self::RuntimeInfo => "runtimeInfo",
255            Self::CreateSession => "createSession",
256            Self::ReadSession => "readSession",
257            Self::DeleteSession => "deleteSession",
258            Self::OpenPage => "openPage",
259            Self::ReadPage => "readPage",
260            Self::ClosePage => "closePage",
261            Self::SubmitCommand => "submitCommand",
262            Self::CreateCheckpoint => "createCheckpoint",
263            Self::ReadCheckpoint => "readCheckpoint",
264            Self::RecoverWorkflow => "recoverWorkflow",
265            Self::ReadArtifact => "readArtifact",
266            Self::ReadContext => "readContext",
267            Self::CaptureArtifact => "captureArtifact",
268            Self::SubscribeEvents => "subscribeEvents",
269            Self::SubmitJob => "submitJob",
270            Self::ReadJob => "readJob",
271            Self::CancelJob => "cancelJob",
272            Self::IssuePrincipal => "issuePrincipal",
273            Self::RevokePrincipal => "revokePrincipal",
274        }
275    }
276
277    pub const fn required(self) -> &'static [Capability] {
278        match self {
279            Self::RuntimeInfo => &[Capability::SessionRead],
280            Self::CreateSession => &[Capability::SessionWrite],
281            Self::ReadSession => &[Capability::SessionRead],
282            Self::DeleteSession => &[Capability::SessionWrite],
283            Self::OpenPage => &[Capability::PageWrite],
284            Self::ReadPage => &[Capability::PageRead],
285            Self::ClosePage => &[Capability::PageWrite],
286            Self::SubmitCommand => &[Capability::BrowserMutate],
287            Self::CreateCheckpoint => &[Capability::RecoveryWrite],
288            Self::ReadCheckpoint => &[Capability::RecoveryRead],
289            Self::RecoverWorkflow => &[Capability::RecoveryWrite],
290            Self::ReadArtifact => &[Capability::ArtifactRead],
291            Self::ReadContext => &[Capability::ContextRead],
292            Self::CaptureArtifact => &[Capability::ArtifactCapture],
293            Self::SubscribeEvents => &[Capability::SessionRead],
294            Self::SubmitJob => &[Capability::JobSubmit],
295            Self::ReadJob => &[Capability::JobRead],
296            Self::CancelJob => &[Capability::JobCancel],
297            Self::IssuePrincipal => &[Capability::AuthorityAdmin],
298            Self::RevokePrincipal => &[Capability::AuthorityAdmin],
299        }
300    }
301}
302
303#[cfg(test)]
304mod interface_operation_tests {
305    use super::InterfaceOperation;
306    use std::collections::HashSet;
307
308    #[test]
309    fn all_is_exhaustive_and_unique() {
310        let expected_names = [
311            "runtimeInfo",
312            "createSession",
313            "readSession",
314            "deleteSession",
315            "openPage",
316            "readPage",
317            "closePage",
318            "submitCommand",
319            "createCheckpoint",
320            "readCheckpoint",
321            "recoverWorkflow",
322            "readArtifact",
323            "readContext",
324            "captureArtifact",
325            "subscribeEvents",
326            "submitJob",
327            "readJob",
328            "cancelJob",
329            "issuePrincipal",
330            "revokePrincipal",
331        ];
332
333        fn listed(operation: InterfaceOperation) {
334            match operation {
335                InterfaceOperation::RuntimeInfo
336                | InterfaceOperation::CreateSession
337                | InterfaceOperation::ReadSession
338                | InterfaceOperation::DeleteSession
339                | InterfaceOperation::OpenPage
340                | InterfaceOperation::ReadPage
341                | InterfaceOperation::ClosePage
342                | InterfaceOperation::SubmitCommand
343                | InterfaceOperation::CreateCheckpoint
344                | InterfaceOperation::ReadCheckpoint
345                | InterfaceOperation::RecoverWorkflow
346                | InterfaceOperation::ReadArtifact
347                | InterfaceOperation::ReadContext
348                | InterfaceOperation::CaptureArtifact
349                | InterfaceOperation::SubscribeEvents
350                | InterfaceOperation::SubmitJob
351                | InterfaceOperation::ReadJob
352                | InterfaceOperation::CancelJob
353                | InterfaceOperation::IssuePrincipal
354                | InterfaceOperation::RevokePrincipal => {}
355            }
356            assert!(InterfaceOperation::ALL.contains(&operation));
357        }
358
359        for (operation, expected_name) in InterfaceOperation::ALL.into_iter().zip(expected_names) {
360            listed(operation);
361            assert_eq!(operation.as_str(), expected_name);
362        }
363        assert_eq!(
364            InterfaceOperation::ALL
365                .into_iter()
366                .collect::<HashSet<_>>()
367                .len(),
368            InterfaceOperation::ALL.len()
369        );
370    }
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
375#[serde(tag = "kind", rename_all = "camelCase")]
376pub enum InterfaceEvent {
377    CommandOutcome {
378        cursor: EventCursor,
379        command_id: CommandId,
380        outcome: CommandOutcome,
381    },
382    ArtifactCaptured {
383        cursor: EventCursor,
384        artifact_id: ArtifactId,
385    },
386    EventGap {
387        earliest_available_cursor: EventCursor,
388    },
389}
390
391#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393#[serde(rename_all = "camelCase")]
394pub enum InterfaceErrorCode {
395    InvalidRequest,
396    UnsupportedInterfaceVersion,
397    InvalidIdempotencyKey,
398    IdempotencyConflict,
399    DeadlineExceeded,
400    AuthenticationFailed,
401    TokenExpired,
402    MissingCapability,
403    MalformedScope,
404    ArtifactDenied,
405    UnsupportedOperation,
406    NotFound,
407    ResourceExhausted,
408    EngineUnreachable,
409    Internal,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[serde(rename_all = "camelCase")]
415pub struct InterfaceError {
416    pub code: InterfaceErrorCode,
417    pub layer: ErrorLayer,
418    pub message: String,
419    pub correlation_id: CorrelationId,
420    pub command_id: Option<CommandId>,
421    pub retryable: bool,
422    pub retry_after_ms: Option<u64>,
423    pub reconciliation_required: bool,
424    pub required_capability: Option<Capability>,
425}
426
427#[derive(Debug, Clone, Error, Eq, PartialEq)]
428pub enum InterfaceValidationError {
429    #[error("unsupported interface version: {0}")]
430    UnsupportedInterfaceVersion(String),
431    #[error("idempotency key must contain 1-128 printable ASCII characters")]
432    InvalidIdempotencyKey,
433    #[error("request deadline must be after dispatch time")]
434    ExpiredDeadline,
435}