Skip to main content

soma_ops/
catalog.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4
5use crate::{
6    AccessClass, DiagnosticCode, OperationName, RetryClass, Reversibility, RiskClass, SchemaId,
7    TargetKind, TargetRef, TargetRefError,
8};
9
10const MAX_PARAMETER_CHARS: usize = 128;
11const MAX_REQUIREMENT_CHARS: usize = 256;
12
13/// Whether an implementation supports a lifecycle capability.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum CapabilitySupport {
19    /// The capability is not implemented.
20    Unsupported,
21    /// The capability is available but not mandatory for every call.
22    Optional,
23    /// The capability is required by the operation contract.
24    Required,
25}
26
27/// Kind of evidence an operation can return for audit or verification.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum EvidenceKind {
33    /// Current runtime state.
34    RuntimeState,
35    /// Bounded logs.
36    Logs,
37    /// Configuration state.
38    Configuration,
39    /// A protected or durable artifact.
40    Artifact,
41    /// A before-and-after difference.
42    Diff,
43    /// Metrics or measurements.
44    Metrics,
45    /// Namespaced evidence understood by a specific engine.
46    Custom(String),
47}
48
49/// One complete set of parameter names required together.
50#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52pub struct ParameterGroup {
53    fields: BTreeSet<String>,
54}
55
56impl ParameterGroup {
57    /// Creates an empty parameter group.
58    #[must_use]
59    pub const fn empty() -> Self {
60        Self {
61            fields: BTreeSet::new(),
62        }
63    }
64
65    /// Creates a validated group from parameter names.
66    pub fn new<I, S>(fields: I) -> Result<Self, SpecError>
67    where
68        I: IntoIterator<Item = S>,
69        S: Into<String>,
70    {
71        let mut validated = BTreeSet::new();
72        for field in fields {
73            let field = field.into();
74            if !valid_parameter_name(&field) {
75                return Err(SpecError::InvalidParameter(field));
76            }
77            if !validated.insert(field.clone()) {
78                return Err(SpecError::DuplicateParameter(field));
79            }
80        }
81        Ok(Self { fields: validated })
82    }
83
84    /// Returns true when the group contains no fields.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.fields.is_empty()
88    }
89
90    /// Iterates over parameter names in deterministic order.
91    pub fn iter(&self) -> impl Iterator<Item = &str> {
92        self.fields.iter().map(String::as_str)
93    }
94}
95
96/// Product-neutral catalog record for one operation.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99pub struct OperationSpec {
100    name: OperationName,
101    schema_version: u32,
102    parameter_schema: SchemaId,
103    result_schema: SchemaId,
104    diagnostic_codes: BTreeSet<DiagnosticCode>,
105    target_kind: TargetKind,
106    access: AccessClass,
107    risk: RiskClass,
108    reversibility: Reversibility,
109    required: ParameterGroup,
110    required_any: Vec<ParameterGroup>,
111    planning: CapabilitySupport,
112    progress: CapabilitySupport,
113    cancellation: CapabilitySupport,
114    verification: CapabilitySupport,
115    fanout: CapabilitySupport,
116    retry: RetryClass,
117    idempotent: bool,
118    evidence: BTreeSet<EvidenceKind>,
119    requirements: BTreeSet<String>,
120}
121
122impl OperationSpec {
123    /// Creates a minimal version-one specification.
124    #[must_use]
125    pub fn new(name: OperationName, target_kind: TargetKind, access: AccessClass) -> Self {
126        let parameter_schema = SchemaId::parameters(&name, 1)
127            .expect("validated operation names produce valid parameter schema IDs");
128        let result_schema = SchemaId::result(&name, 1)
129            .expect("validated operation names produce valid result schema IDs");
130        Self {
131            name,
132            schema_version: 1,
133            parameter_schema,
134            result_schema,
135            diagnostic_codes: BTreeSet::new(),
136            target_kind,
137            access,
138            risk: RiskClass::Safe,
139            reversibility: Reversibility::Reversible,
140            required: ParameterGroup::empty(),
141            required_any: Vec::new(),
142            planning: CapabilitySupport::Unsupported,
143            progress: CapabilitySupport::Unsupported,
144            cancellation: CapabilitySupport::Unsupported,
145            verification: CapabilitySupport::Unsupported,
146            fanout: CapabilitySupport::Unsupported,
147            retry: RetryClass::Never,
148            idempotent: false,
149            evidence: BTreeSet::new(),
150            requirements: BTreeSet::new(),
151        }
152    }
153
154    /// Sets the serialized contract version.
155    #[must_use]
156    pub fn with_schema_version(mut self, schema_version: u32) -> Self {
157        self.schema_version = schema_version;
158        if let Ok(schema) = SchemaId::parameters(&self.name, schema_version) {
159            self.parameter_schema = schema;
160        }
161        if let Ok(schema) = SchemaId::result(&self.name, schema_version) {
162            self.result_schema = schema;
163        }
164        self
165    }
166
167    /// Sets explicit parameter and result schema identities.
168    #[must_use]
169    pub fn with_schema_ids(mut self, parameter_schema: SchemaId, result_schema: SchemaId) -> Self {
170        self.parameter_schema = parameter_schema;
171        self.result_schema = result_schema;
172        self
173    }
174
175    /// Declares a stable diagnostic code that the operation may emit.
176    #[must_use]
177    pub fn with_diagnostic_code(mut self, code: DiagnosticCode) -> Self {
178        self.diagnostic_codes.insert(code);
179        self
180    }
181
182    /// Sets risk and reversibility metadata.
183    #[must_use]
184    pub fn with_safety(mut self, risk: RiskClass, reversibility: Reversibility) -> Self {
185        self.risk = risk;
186        self.reversibility = reversibility;
187        self
188    }
189
190    /// Sets fields required for every request.
191    #[must_use]
192    pub fn with_required(mut self, required: ParameterGroup) -> Self {
193        self.required = required;
194        self
195    }
196
197    /// Adds an alternative complete parameter group.
198    #[must_use]
199    pub fn with_required_any(mut self, group: ParameterGroup) -> Self {
200        self.required_any.push(group);
201        self
202    }
203
204    /// Sets lifecycle and fanout capability support.
205    #[must_use]
206    pub fn with_lifecycle(
207        mut self,
208        planning: CapabilitySupport,
209        progress: CapabilitySupport,
210        cancellation: CapabilitySupport,
211        verification: CapabilitySupport,
212        fanout: CapabilitySupport,
213    ) -> Self {
214        self.planning = planning;
215        self.progress = progress;
216        self.cancellation = cancellation;
217        self.verification = verification;
218        self.fanout = fanout;
219        self
220    }
221
222    /// Sets retry behavior and whether mutations are idempotent.
223    #[must_use]
224    pub fn with_retry(mut self, retry: RetryClass, idempotent: bool) -> Self {
225        self.retry = retry;
226        self.idempotent = idempotent;
227        self
228    }
229
230    /// Adds an evidence kind returned by the operation.
231    #[must_use]
232    pub fn with_evidence(mut self, evidence: EvidenceKind) -> Self {
233        self.evidence.insert(evidence);
234        self
235    }
236
237    /// Adds an implementation capability requirement such as `transport.ssh`.
238    pub fn with_requirement(mut self, requirement: impl Into<String>) -> Result<Self, SpecError> {
239        let requirement = requirement.into();
240        if !valid_requirement(&requirement) {
241            return Err(SpecError::InvalidRequirement(requirement));
242        }
243        self.requirements.insert(requirement);
244        Ok(self)
245    }
246
247    /// Validates cross-field safety and compatibility invariants.
248    pub fn validate(&self) -> Result<(), SpecError> {
249        if self.schema_version == 0 {
250            return Err(SpecError::ZeroSchemaVersion);
251        }
252        let expected_parameters = SchemaId::parameters(&self.name, self.schema_version)
253            .map_err(|_| SpecError::SchemaIdentityMismatch)?;
254        let expected_result = SchemaId::result(&self.name, self.schema_version)
255            .map_err(|_| SpecError::SchemaIdentityMismatch)?;
256        if self.parameter_schema != expected_parameters || self.result_schema != expected_result {
257            return Err(SpecError::SchemaIdentityMismatch);
258        }
259        if self.required_any.iter().any(ParameterGroup::is_empty) {
260            return Err(SpecError::EmptyAlternative);
261        }
262        let mut alternatives = BTreeSet::new();
263        for group in &self.required_any {
264            if !alternatives.insert(group) {
265                return Err(SpecError::DuplicateAlternative);
266            }
267        }
268        if self.access == AccessClass::Read && self.risk == RiskClass::Destructive {
269            return Err(SpecError::DestructiveRead);
270        }
271        if self.access == AccessClass::Read && self.idempotent {
272            return Err(SpecError::ReadMarkedIdempotent);
273        }
274        if self.access == AccessClass::Mutation
275            && self.retry == RetryClass::Safe
276            && !self.idempotent
277        {
278            return Err(SpecError::UnsafeRetryClaim);
279        }
280        if self.access == AccessClass::Mutation
281            && self.risk >= RiskClass::Destructive
282            && self.planning == CapabilitySupport::Unsupported
283        {
284            return Err(SpecError::RiskyMutationWithoutPlan);
285        }
286        Ok(())
287    }
288
289    /// Returns the canonical operation name.
290    #[must_use]
291    pub fn name(&self) -> &OperationName {
292        &self.name
293    }
294
295    /// Returns the contract schema version.
296    #[must_use]
297    pub const fn schema_version(&self) -> u32 {
298        self.schema_version
299    }
300
301    /// Returns the versioned parameter schema identity.
302    #[must_use]
303    pub fn parameter_schema(&self) -> &SchemaId {
304        &self.parameter_schema
305    }
306
307    /// Returns the versioned result schema identity.
308    #[must_use]
309    pub fn result_schema(&self) -> &SchemaId {
310        &self.result_schema
311    }
312
313    /// Iterates over stable diagnostic codes declared by the operation.
314    pub fn diagnostic_codes(&self) -> impl Iterator<Item = &DiagnosticCode> {
315        self.diagnostic_codes.iter()
316    }
317
318    /// Returns whether the operation contract declares this diagnostic code.
319    #[must_use]
320    pub fn allows_diagnostic(&self, code: &DiagnosticCode) -> bool {
321        self.diagnostic_codes.contains(code)
322    }
323
324    /// Returns the required target kind.
325    #[must_use]
326    pub fn target_kind(&self) -> &TargetKind {
327        &self.target_kind
328    }
329
330    /// Returns the access class.
331    #[must_use]
332    pub const fn access(&self) -> AccessClass {
333        self.access
334    }
335
336    /// Returns the risk class.
337    #[must_use]
338    pub const fn risk(&self) -> RiskClass {
339        self.risk
340    }
341
342    /// Returns expected reversibility.
343    #[must_use]
344    pub const fn reversibility(&self) -> Reversibility {
345        self.reversibility
346    }
347
348    /// Returns fields required for every request.
349    #[must_use]
350    pub fn required(&self) -> &ParameterGroup {
351        &self.required
352    }
353
354    /// Returns alternative complete parameter groups.
355    #[must_use]
356    pub fn required_any(&self) -> &[ParameterGroup] {
357        &self.required_any
358    }
359
360    /// Returns planning support.
361    #[must_use]
362    pub const fn planning(&self) -> CapabilitySupport {
363        self.planning
364    }
365
366    /// Returns progress support.
367    #[must_use]
368    pub const fn progress(&self) -> CapabilitySupport {
369        self.progress
370    }
371
372    /// Returns cancellation support.
373    #[must_use]
374    pub const fn cancellation(&self) -> CapabilitySupport {
375        self.cancellation
376    }
377
378    /// Returns verification support.
379    #[must_use]
380    pub const fn verification(&self) -> CapabilitySupport {
381        self.verification
382    }
383
384    /// Returns fanout support.
385    #[must_use]
386    pub const fn fanout(&self) -> CapabilitySupport {
387        self.fanout
388    }
389
390    /// Returns retry classification.
391    #[must_use]
392    pub const fn retry(&self) -> RetryClass {
393        self.retry
394    }
395
396    /// Returns whether mutation repetition is idempotent.
397    #[must_use]
398    pub const fn idempotent(&self) -> bool {
399        self.idempotent
400    }
401
402    /// Iterates over expected evidence kinds.
403    pub fn evidence(&self) -> impl Iterator<Item = &EvidenceKind> {
404        self.evidence.iter()
405    }
406
407    /// Iterates over implementation capability requirements.
408    pub fn requirements(&self) -> impl Iterator<Item = &str> {
409        self.requirements.iter().map(String::as_str)
410    }
411}
412
413/// Typed definition implemented by a concrete infrastructure operation.
414pub trait OperationDefinition {
415    /// Operation-specific request parameters.
416    type Parameters: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
417    /// Operation-specific successful output.
418    type Output: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
419
420    /// Returns the operation catalog specification.
421    fn spec() -> OperationSpec;
422
423    /// Resolves the request parameters to a typed target.
424    fn target(parameters: &Self::Parameters) -> Result<TargetRef, TargetRefError>;
425}
426
427/// Invalid operation catalog metadata.
428#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
429#[non_exhaustive]
430pub enum SpecError {
431    /// Schema version zero is invalid.
432    #[error("operation schema version must be greater than zero")]
433    ZeroSchemaVersion,
434    /// Parameter or result schema identity did not match the operation and version.
435    #[error("operation schema identity does not match operation name and version")]
436    SchemaIdentityMismatch,
437    /// A parameter name is invalid.
438    #[error("invalid operation parameter name: {0}")]
439    InvalidParameter(String),
440    /// A parameter appears more than once in a group.
441    #[error("duplicate operation parameter: {0}")]
442    DuplicateParameter(String),
443    /// An alternative parameter group is empty.
444    #[error("alternative parameter groups must not be empty")]
445    EmptyAlternative,
446    /// The same alternative parameter group appears more than once.
447    #[error("duplicate alternative parameter group")]
448    DuplicateAlternative,
449    /// A read operation was incorrectly classified as destructive.
450    #[error("read operations cannot be classified as destructive")]
451    DestructiveRead,
452    /// Read operations do not use mutation idempotency metadata.
453    #[error("read operations cannot be marked as idempotent mutations")]
454    ReadMarkedIdempotent,
455    /// A non-idempotent mutation claimed safe automatic retry.
456    #[error("safe retry for a mutation requires idempotent behavior")]
457    UnsafeRetryClaim,
458    /// A destructive or privileged mutation omitted planning support.
459    #[error("destructive and privileged mutations require planning support")]
460    RiskyMutationWithoutPlan,
461    /// An implementation capability requirement is invalid.
462    #[error("invalid operation capability requirement: {0}")]
463    InvalidRequirement(String),
464}
465
466fn valid_parameter_name(value: &str) -> bool {
467    let mut chars = value.chars();
468    let count = value.chars().count();
469    count <= MAX_PARAMETER_CHARS
470        && matches!(chars.next(), Some('a'..='z'))
471        && chars.all(|character| {
472            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
473        })
474        && !value.ends_with('_')
475}
476
477fn valid_requirement(value: &str) -> bool {
478    let count = value.chars().count();
479    count > 0
480        && count <= MAX_REQUIREMENT_CHARS
481        && !value.chars().any(char::is_control)
482        && value.split('.').all(valid_name_segment)
483}
484
485fn valid_name_segment(value: &str) -> bool {
486    let mut chars = value.chars();
487    matches!(chars.next(), Some('a'..='z'))
488        && chars.all(|character| {
489            character.is_ascii_lowercase()
490                || character.is_ascii_digit()
491                || matches!(character, '-' | '_')
492        })
493        && !value.ends_with(['-', '_'])
494}
495
496#[cfg(test)]
497#[path = "catalog_tests.rs"]
498mod tests;