Skip to main content

soma_ops/
model.rs

1use std::{fmt, str::FromStr};
2
3use serde::{Deserialize, Serialize};
4
5const MAX_OPERATION_NAME_CHARS: usize = 128;
6const MAX_TARGET_VALUE_CHARS: usize = 1_024;
7const MAX_TARGET_DEPTH: usize = 8;
8const MAX_IDEMPOTENCY_KEY_CHARS: usize = 256;
9
10/// Stable dotted operation identity such as `container.restart`.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(transparent)]
14pub struct OperationName(String);
15
16impl OperationName {
17    /// Creates a validated dotted operation name.
18    pub fn new(value: impl Into<String>) -> Result<Self, OperationNameError> {
19        let value = value.into();
20        if valid_operation_name(&value) {
21            Ok(Self(value))
22        } else {
23            Err(OperationNameError(value))
24        }
25    }
26
27    /// Returns the operation name.
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32
33    /// Consumes the name and returns the owned string.
34    #[must_use]
35    pub fn into_string(self) -> String {
36        self.0
37    }
38}
39
40impl fmt::Display for OperationName {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(&self.0)
43    }
44}
45
46impl FromStr for OperationName {
47    type Err = OperationNameError;
48
49    fn from_str(value: &str) -> Result<Self, Self::Err> {
50        Self::new(value)
51    }
52}
53
54/// Error returned for an invalid canonical operation name.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[error("invalid operation name {0}; expected lowercase dotted segments")]
57pub struct OperationNameError(String);
58
59fn valid_operation_name(value: &str) -> bool {
60    let length = value.chars().count();
61    if !(3..=MAX_OPERATION_NAME_CHARS).contains(&length) || !value.contains('.') {
62        return false;
63    }
64    value.split('.').all(valid_name_segment)
65}
66
67fn valid_name_segment(segment: &str) -> bool {
68    let mut chars = segment.chars();
69    matches!(chars.next(), Some('a'..='z'))
70        && chars.all(|character| {
71            character.is_ascii_lowercase()
72                || character.is_ascii_digit()
73                || matches!(character, '-' | '_')
74        })
75        && !segment.ends_with(['-', '_'])
76}
77
78/// Typed category of a resource targeted by an operation.
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "snake_case")]
82#[non_exhaustive]
83pub enum TargetKind {
84    /// A physical or virtual host.
85    Host,
86    /// A Docker-compatible daemon.
87    DockerDaemon,
88    /// A Docker or OCI container.
89    Container,
90    /// A Compose project.
91    ComposeProject,
92    /// An Incus daemon.
93    IncusServer,
94    /// An Incus container or virtual machine.
95    IncusInstance,
96    /// A container or Incus image.
97    Image,
98    /// A network resource.
99    Network,
100    /// A storage pool.
101    StoragePool,
102    /// A storage volume.
103    StorageVolume,
104    /// A filesystem path.
105    File,
106    /// A process.
107    Process,
108    /// A log source.
109    LogSource,
110    /// A ZFS pool.
111    ZfsPool,
112    /// A ZFS dataset.
113    ZfsDataset,
114    /// A ZFS snapshot.
115    ZfsSnapshot,
116    /// A namespaced target kind defined by an external engine.
117    Custom(OperationName),
118}
119
120/// Stable, serializable reference to an operation target.
121#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct TargetRef {
124    kind: TargetKind,
125    id: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    host: Option<String>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    parent: Option<Box<TargetRef>>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    revision: Option<String>,
132}
133
134impl TargetRef {
135    /// Creates a target reference with a validated resource identity.
136    pub fn new(kind: TargetKind, id: impl Into<String>) -> Result<Self, TargetRefError> {
137        let id = id.into();
138        validate_target_value("id", &id)?;
139        Ok(Self {
140            kind,
141            id,
142            host: None,
143            parent: None,
144            revision: None,
145        })
146    }
147
148    /// Associates the target with an explicit host identity.
149    pub fn with_host(mut self, host: impl Into<String>) -> Result<Self, TargetRefError> {
150        let host = host.into();
151        validate_target_value("host", &host)?;
152        self.host = Some(host);
153        Ok(self)
154    }
155
156    /// Associates the target with a parent resource.
157    pub fn with_parent(mut self, parent: TargetRef) -> Result<Self, TargetRefError> {
158        if parent.depth() >= MAX_TARGET_DEPTH {
159            return Err(TargetRefError::ExcessiveDepth {
160                max_depth: MAX_TARGET_DEPTH,
161            });
162        }
163        self.parent = Some(Box::new(parent));
164        Ok(self)
165    }
166
167    /// Binds the target to a topology or resource revision.
168    pub fn with_revision(mut self, revision: impl Into<String>) -> Result<Self, TargetRefError> {
169        let revision = revision.into();
170        validate_target_value("revision", &revision)?;
171        self.revision = Some(revision);
172        Ok(self)
173    }
174
175    /// Returns the target category.
176    #[must_use]
177    pub fn kind(&self) -> &TargetKind {
178        &self.kind
179    }
180
181    /// Returns the target identity.
182    #[must_use]
183    pub fn id(&self) -> &str {
184        &self.id
185    }
186
187    /// Returns the explicit host identity when present.
188    #[must_use]
189    pub fn host(&self) -> Option<&str> {
190        self.host.as_deref()
191    }
192
193    /// Returns the parent target when present.
194    #[must_use]
195    pub fn parent(&self) -> Option<&TargetRef> {
196        self.parent.as_deref()
197    }
198
199    /// Returns the bound revision when present.
200    #[must_use]
201    pub fn revision(&self) -> Option<&str> {
202        self.revision.as_deref()
203    }
204
205    fn depth(&self) -> usize {
206        1 + self.parent.as_deref().map_or(0, Self::depth)
207    }
208}
209
210/// Validation failure for a target reference.
211#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
212#[non_exhaustive]
213pub enum TargetRefError {
214    /// A textual target field was empty, oversized, or contained control characters.
215    #[error("invalid target {field}: expected 1..={max_chars} non-control characters")]
216    InvalidValue {
217        /// Target field.
218        field: &'static str,
219        /// Maximum accepted character count.
220        max_chars: usize,
221    },
222    /// The parent chain exceeded the defensive recursion bound.
223    #[error("target parent chain exceeds maximum depth {max_depth}")]
224    ExcessiveDepth {
225        /// Maximum parent depth.
226        max_depth: usize,
227    },
228}
229
230fn validate_target_value(field: &'static str, value: &str) -> Result<(), TargetRefError> {
231    let chars = value.chars().count();
232    if chars == 0 || chars > MAX_TARGET_VALUE_CHARS || value.chars().any(char::is_control) {
233        return Err(TargetRefError::InvalidValue {
234            field,
235            max_chars: MAX_TARGET_VALUE_CHARS,
236        });
237    }
238    Ok(())
239}
240
241/// Caller-provided key that makes a supported mutation safely repeatable.
242#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[serde(transparent)]
245pub struct IdempotencyKey(String);
246
247impl IdempotencyKey {
248    /// Creates a bounded idempotency key.
249    pub fn new(value: impl Into<String>) -> Result<Self, IdempotencyKeyError> {
250        let value = value.into();
251        let chars = value.chars().count();
252        if chars == 0 || chars > MAX_IDEMPOTENCY_KEY_CHARS || value.chars().any(char::is_control) {
253            return Err(IdempotencyKeyError);
254        }
255        Ok(Self(value))
256    }
257
258    /// Returns the idempotency key.
259    #[must_use]
260    pub fn as_str(&self) -> &str {
261        &self.0
262    }
263}
264
265/// Error returned for an invalid idempotency key.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
267#[error("invalid idempotency key")]
268pub struct IdempotencyKeyError;
269
270/// Whether an operation only observes state or may mutate it.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273#[serde(rename_all = "snake_case")]
274#[non_exhaustive]
275pub enum AccessClass {
276    /// Observation with no intended external mutation.
277    Read,
278    /// Operation that may change external state.
279    Mutation,
280}
281
282/// Operational risk independent of product-specific authorization policy.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[serde(rename_all = "snake_case")]
286#[non_exhaustive]
287pub enum RiskClass {
288    /// Expected to be non-disruptive.
289    Safe,
290    /// May interrupt availability or active work.
291    Disruptive,
292    /// May destroy data or resources.
293    Destructive,
294    /// Exercises host-level or otherwise elevated authority.
295    Privileged,
296}
297
298/// Expected ability to reverse an operation's effect.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[serde(rename_all = "snake_case")]
302#[non_exhaustive]
303pub enum Reversibility {
304    /// The operation has a defined inverse under normal conditions.
305    Reversible,
306    /// Reversal depends on snapshots, backups, or runtime conditions.
307    Conditional,
308    /// The operation has no general rollback.
309    Irreversible,
310}
311
312/// Whether a failed call may be retried automatically.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315#[serde(rename_all = "snake_case")]
316#[non_exhaustive]
317pub enum RetryClass {
318    /// Retrying is unsafe without operator analysis.
319    Never,
320    /// Retrying is safe because no mutation was sent or the operation is idempotent.
321    Safe,
322    /// Retrying depends on structured failure details.
323    Conditional,
324}
325
326/// Whether a failed operation may already have reached the target.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329#[serde(rename_all = "snake_case")]
330#[non_exhaustive]
331pub enum MutationSendState {
332    /// No mutation request was sent.
333    NotSent,
334    /// The mutation request was sent.
335    Sent,
336    /// The transport cannot determine whether the mutation was sent.
337    Unknown,
338    /// The operation was read-only.
339    NotApplicable,
340}
341
342/// Independent verification outcome after execution completes.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
344#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
345#[serde(rename_all = "snake_case")]
346#[non_exhaustive]
347pub enum VerificationStatus {
348    /// Runtime evidence confirms the intended state.
349    Verified,
350    /// Runtime evidence contradicts the intended state.
351    Failed,
352    /// Evidence was insufficient to decide.
353    Inconclusive,
354    /// The implementation has no verification operation.
355    NotSupported,
356    /// Verification was not requested.
357    NotRequested,
358}
359
360#[cfg(test)]
361#[path = "model_tests.rs"]
362mod tests;