1use 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
12pub const CURRENT_INTERFACE_VERSION: &str = "2026-07-23";
14
15#[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#[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#[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 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#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
204pub enum InterfaceOperation {
205 RuntimeInfo,
206 CreateSession,
207 ReadSession,
208 DeleteSession,
209 OpenPage,
210 ReadPage,
211 ClosePage,
212 SubmitCommand,
213 CreateCheckpoint,
214 ReadCheckpoint,
215 RecoverWorkflow,
216 ReadArtifact,
217 CaptureArtifact,
218 SubscribeEvents,
219 SubmitJob,
220 ReadJob,
221 CancelJob,
222 IssuePrincipal,
223 RevokePrincipal,
224}
225
226impl InterfaceOperation {
227 pub const fn required(self) -> &'static [Capability] {
228 match self {
229 Self::RuntimeInfo => &[Capability::SessionRead],
230 Self::CreateSession => &[Capability::SessionWrite],
231 Self::ReadSession => &[Capability::SessionRead],
232 Self::DeleteSession => &[Capability::SessionWrite],
233 Self::OpenPage => &[Capability::PageWrite],
234 Self::ReadPage => &[Capability::PageRead],
235 Self::ClosePage => &[Capability::PageWrite],
236 Self::SubmitCommand => &[Capability::BrowserMutate],
237 Self::CreateCheckpoint => &[Capability::RecoveryWrite],
238 Self::ReadCheckpoint => &[Capability::RecoveryRead],
239 Self::RecoverWorkflow => &[Capability::RecoveryWrite],
240 Self::ReadArtifact => &[Capability::ArtifactRead],
241 Self::CaptureArtifact => &[Capability::ArtifactCapture],
242 Self::SubscribeEvents => &[Capability::SessionRead],
243 Self::SubmitJob => &[Capability::JobSubmit],
244 Self::ReadJob => &[Capability::JobRead],
245 Self::CancelJob => &[Capability::JobCancel],
246 Self::IssuePrincipal => &[Capability::AuthorityAdmin],
247 Self::RevokePrincipal => &[Capability::AuthorityAdmin],
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
254#[serde(tag = "kind", rename_all = "camelCase")]
255pub enum InterfaceEvent {
256 CommandOutcome {
257 cursor: EventCursor,
258 command_id: CommandId,
259 outcome: CommandOutcome,
260 },
261 ArtifactCaptured {
262 cursor: EventCursor,
263 artifact_id: ArtifactId,
264 },
265 EventGap {
266 earliest_available_cursor: EventCursor,
267 },
268}
269
270#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(rename_all = "camelCase")]
273pub enum InterfaceErrorCode {
274 InvalidRequest,
275 UnsupportedInterfaceVersion,
276 InvalidIdempotencyKey,
277 IdempotencyConflict,
278 DeadlineExceeded,
279 AuthenticationFailed,
280 TokenExpired,
281 MissingCapability,
282 MalformedScope,
283 ArtifactDenied,
284 UnsupportedOperation,
285 NotFound,
286 ResourceExhausted,
287 Internal,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[serde(rename_all = "camelCase")]
293pub struct InterfaceError {
294 pub code: InterfaceErrorCode,
295 pub layer: ErrorLayer,
296 pub message: String,
297 pub correlation_id: CorrelationId,
298 pub command_id: Option<CommandId>,
299 pub retryable: bool,
300 pub retry_after_ms: Option<u64>,
301 pub reconciliation_required: bool,
302 pub required_capability: Option<Capability>,
303}
304
305#[derive(Debug, Clone, Error, Eq, PartialEq)]
306pub enum InterfaceValidationError {
307 #[error("unsupported interface version: {0}")]
308 UnsupportedInterfaceVersion(String),
309 #[error("idempotency key must contain 1-128 printable ASCII characters")]
310 InvalidIdempotencyKey,
311 #[error("request deadline must be after dispatch time")]
312 ExpiredDeadline,
313}