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-08-19";
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, 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}