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#[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 Unsupported,
21 Optional,
23 Required,
25}
26
27#[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 RuntimeState,
35 Logs,
37 Configuration,
39 Artifact,
41 Diff,
43 Metrics,
45 Custom(String),
47}
48
49#[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 #[must_use]
59 pub const fn empty() -> Self {
60 Self {
61 fields: BTreeSet::new(),
62 }
63 }
64
65 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 #[must_use]
86 pub fn is_empty(&self) -> bool {
87 self.fields.is_empty()
88 }
89
90 pub fn iter(&self) -> impl Iterator<Item = &str> {
92 self.fields.iter().map(String::as_str)
93 }
94}
95
96#[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 #[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 #[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 #[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 #[must_use]
177 pub fn with_diagnostic_code(mut self, code: DiagnosticCode) -> Self {
178 self.diagnostic_codes.insert(code);
179 self
180 }
181
182 #[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 #[must_use]
192 pub fn with_required(mut self, required: ParameterGroup) -> Self {
193 self.required = required;
194 self
195 }
196
197 #[must_use]
199 pub fn with_required_any(mut self, group: ParameterGroup) -> Self {
200 self.required_any.push(group);
201 self
202 }
203
204 #[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 #[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 #[must_use]
232 pub fn with_evidence(mut self, evidence: EvidenceKind) -> Self {
233 self.evidence.insert(evidence);
234 self
235 }
236
237 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 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 #[must_use]
291 pub fn name(&self) -> &OperationName {
292 &self.name
293 }
294
295 #[must_use]
297 pub const fn schema_version(&self) -> u32 {
298 self.schema_version
299 }
300
301 #[must_use]
303 pub fn parameter_schema(&self) -> &SchemaId {
304 &self.parameter_schema
305 }
306
307 #[must_use]
309 pub fn result_schema(&self) -> &SchemaId {
310 &self.result_schema
311 }
312
313 pub fn diagnostic_codes(&self) -> impl Iterator<Item = &DiagnosticCode> {
315 self.diagnostic_codes.iter()
316 }
317
318 #[must_use]
320 pub fn allows_diagnostic(&self, code: &DiagnosticCode) -> bool {
321 self.diagnostic_codes.contains(code)
322 }
323
324 #[must_use]
326 pub fn target_kind(&self) -> &TargetKind {
327 &self.target_kind
328 }
329
330 #[must_use]
332 pub const fn access(&self) -> AccessClass {
333 self.access
334 }
335
336 #[must_use]
338 pub const fn risk(&self) -> RiskClass {
339 self.risk
340 }
341
342 #[must_use]
344 pub const fn reversibility(&self) -> Reversibility {
345 self.reversibility
346 }
347
348 #[must_use]
350 pub fn required(&self) -> &ParameterGroup {
351 &self.required
352 }
353
354 #[must_use]
356 pub fn required_any(&self) -> &[ParameterGroup] {
357 &self.required_any
358 }
359
360 #[must_use]
362 pub const fn planning(&self) -> CapabilitySupport {
363 self.planning
364 }
365
366 #[must_use]
368 pub const fn progress(&self) -> CapabilitySupport {
369 self.progress
370 }
371
372 #[must_use]
374 pub const fn cancellation(&self) -> CapabilitySupport {
375 self.cancellation
376 }
377
378 #[must_use]
380 pub const fn verification(&self) -> CapabilitySupport {
381 self.verification
382 }
383
384 #[must_use]
386 pub const fn fanout(&self) -> CapabilitySupport {
387 self.fanout
388 }
389
390 #[must_use]
392 pub const fn retry(&self) -> RetryClass {
393 self.retry
394 }
395
396 #[must_use]
398 pub const fn idempotent(&self) -> bool {
399 self.idempotent
400 }
401
402 pub fn evidence(&self) -> impl Iterator<Item = &EvidenceKind> {
404 self.evidence.iter()
405 }
406
407 pub fn requirements(&self) -> impl Iterator<Item = &str> {
409 self.requirements.iter().map(String::as_str)
410 }
411}
412
413pub trait OperationDefinition {
415 type Parameters: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
417 type Output: Clone + Serialize + DeserializeOwned + Send + Sync + 'static;
419
420 fn spec() -> OperationSpec;
422
423 fn target(parameters: &Self::Parameters) -> Result<TargetRef, TargetRefError>;
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
429#[non_exhaustive]
430pub enum SpecError {
431 #[error("operation schema version must be greater than zero")]
433 ZeroSchemaVersion,
434 #[error("operation schema identity does not match operation name and version")]
436 SchemaIdentityMismatch,
437 #[error("invalid operation parameter name: {0}")]
439 InvalidParameter(String),
440 #[error("duplicate operation parameter: {0}")]
442 DuplicateParameter(String),
443 #[error("alternative parameter groups must not be empty")]
445 EmptyAlternative,
446 #[error("duplicate alternative parameter group")]
448 DuplicateAlternative,
449 #[error("read operations cannot be classified as destructive")]
451 DestructiveRead,
452 #[error("read operations cannot be marked as idempotent mutations")]
454 ReadMarkedIdempotent,
455 #[error("safe retry for a mutation requires idempotent behavior")]
457 UnsafeRetryClaim,
458 #[error("destructive and privileged mutations require planning support")]
460 RiskyMutationWithoutPlan,
461 #[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;