Skip to main content

soma_ops/
result.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::{
5    DiagnosticCode, MutationSendState, OperationId, OperationName, RetryClass, TargetRef,
6    Timestamp, VerificationStatus,
7};
8
9const MAX_URI_CHARS: usize = 2_048;
10const MAX_MEDIA_TYPE_CHARS: usize = 256;
11const MAX_DIAGNOSTIC_CHARS: usize = 4_096;
12const MAX_REDACTION_LABEL_CHARS: usize = 128;
13
14/// Terminal operation status before independent verification.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17#[serde(rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum OperationStatus {
20    /// Execution completed successfully.
21    Succeeded,
22    /// Execution failed.
23    Failed,
24    /// Execution was cancelled.
25    Cancelled,
26}
27
28/// Severity of one structured diagnostic.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum DiagnosticSeverity {
34    /// Informational context.
35    Info,
36    /// Recoverable or degraded behavior.
37    Warning,
38    /// Operation failure or invalid state.
39    Error,
40}
41
42/// Correctable diagnostic returned by an operation engine.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct Diagnostic {
46    code: DiagnosticCode,
47    severity: DiagnosticSeverity,
48    message: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    field: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    next_action: Option<String>,
53}
54
55impl Diagnostic {
56    /// Creates a validated diagnostic.
57    pub fn new(
58        code: impl Into<String>,
59        severity: DiagnosticSeverity,
60        message: impl Into<String>,
61    ) -> Result<Self, ResultError> {
62        let code = code.into();
63        let message = message.into();
64        let code = DiagnosticCode::new(code.clone()).map_err(|_| ResultError::InvalidCode(code))?;
65        validate_text("diagnostic message", &message, MAX_DIAGNOSTIC_CHARS)?;
66        Ok(Self {
67            code,
68            severity,
69            message,
70            field: None,
71            next_action: None,
72        })
73    }
74
75    /// Adds the request field associated with the diagnostic.
76    pub fn with_field(mut self, field: impl Into<String>) -> Result<Self, ResultError> {
77        let field = field.into();
78        validate_code(&field)?;
79        self.field = Some(field);
80        Ok(self)
81    }
82
83    /// Adds a bounded corrective next action.
84    pub fn with_next_action(mut self, next_action: impl Into<String>) -> Result<Self, ResultError> {
85        let next_action = next_action.into();
86        validate_text("diagnostic next action", &next_action, MAX_DIAGNOSTIC_CHARS)?;
87        self.next_action = Some(next_action);
88        Ok(self)
89    }
90
91    /// Returns the stable diagnostic code.
92    #[must_use]
93    pub fn code(&self) -> &str {
94        self.code.as_str()
95    }
96
97    /// Returns the typed stable diagnostic code.
98    #[must_use]
99    pub fn code_id(&self) -> &DiagnosticCode {
100        &self.code
101    }
102
103    /// Returns diagnostic severity.
104    #[must_use]
105    pub const fn severity(&self) -> DiagnosticSeverity {
106        self.severity
107    }
108
109    /// Returns the human-readable diagnostic.
110    #[must_use]
111    pub fn message(&self) -> &str {
112        &self.message
113    }
114}
115
116/// Reference to a protected or durable operation artifact.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct ArtifactRef {
120    uri: String,
121    media_type: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    sha256: Option<String>,
124    protected: bool,
125}
126
127impl ArtifactRef {
128    /// Creates a validated artifact reference.
129    pub fn new(
130        uri: impl Into<String>,
131        media_type: impl Into<String>,
132        protected: bool,
133    ) -> Result<Self, ResultError> {
134        let uri = uri.into();
135        let media_type = media_type.into();
136        validate_text("artifact URI", &uri, MAX_URI_CHARS)?;
137        validate_text("artifact media type", &media_type, MAX_MEDIA_TYPE_CHARS)?;
138        Ok(Self {
139            uri,
140            media_type,
141            sha256: None,
142            protected,
143        })
144    }
145
146    /// Adds a lowercase SHA-256 content digest.
147    pub fn with_sha256(mut self, sha256: impl Into<String>) -> Result<Self, ResultError> {
148        let sha256 = sha256.into();
149        validate_sha256(&sha256)?;
150        self.sha256 = Some(sha256);
151        Ok(self)
152    }
153
154    /// Returns the artifact URI.
155    #[must_use]
156    pub fn uri(&self) -> &str {
157        &self.uri
158    }
159
160    /// Returns whether access should require protected artifact policy.
161    #[must_use]
162    pub const fn protected(&self) -> bool {
163        self.protected
164    }
165}
166
167/// Reference to evidence captured by an operation.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct EvidenceRef {
171    kind: String,
172    reference: String,
173}
174
175impl EvidenceRef {
176    /// Creates a validated evidence reference.
177    pub fn new(kind: impl Into<String>, reference: impl Into<String>) -> Result<Self, ResultError> {
178        let kind = kind.into();
179        let reference = reference.into();
180        validate_code(&kind)?;
181        validate_text("evidence reference", &reference, MAX_URI_CHARS)?;
182        Ok(Self { kind, reference })
183    }
184
185    /// Returns the evidence kind.
186    #[must_use]
187    pub fn kind(&self) -> &str {
188        &self.kind
189    }
190
191    /// Returns the evidence locator.
192    #[must_use]
193    pub fn reference(&self) -> &str {
194        &self.reference
195    }
196}
197
198/// Description of fields or artifacts removed from a result.
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201pub struct RedactionMetadata {
202    applied: bool,
203    labels: Vec<String>,
204}
205
206impl RedactionMetadata {
207    /// Creates empty redaction metadata.
208    #[must_use]
209    pub const fn none() -> Self {
210        Self {
211            applied: false,
212            labels: Vec::new(),
213        }
214    }
215
216    /// Records a redaction label.
217    pub fn with_label(mut self, label: impl Into<String>) -> Result<Self, ResultError> {
218        let label = label.into();
219        validate_text("redaction label", &label, MAX_REDACTION_LABEL_CHARS)?;
220        self.applied = true;
221        if !self.labels.contains(&label) {
222            self.labels.push(label);
223            self.labels.sort();
224        }
225        Ok(self)
226    }
227
228    /// Returns whether any redaction was applied.
229    #[must_use]
230    pub const fn applied(&self) -> bool {
231        self.applied
232    }
233
234    /// Returns sorted unique redaction labels.
235    #[must_use]
236    pub fn labels(&self) -> &[String] {
237        &self.labels
238    }
239}
240
241/// Independent runtime-state verification result.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244pub struct VerificationResult {
245    status: VerificationStatus,
246    completed_at: Timestamp,
247    diagnostics: Vec<Diagnostic>,
248    evidence: Vec<EvidenceRef>,
249}
250
251impl VerificationResult {
252    /// Creates a verification result.
253    #[must_use]
254    pub const fn new(status: VerificationStatus, completed_at: Timestamp) -> Self {
255        Self {
256            status,
257            completed_at,
258            diagnostics: Vec::new(),
259            evidence: Vec::new(),
260        }
261    }
262
263    /// Adds a structured diagnostic.
264    #[must_use]
265    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
266        self.diagnostics.push(diagnostic);
267        self
268    }
269
270    /// Adds evidence supporting the verification outcome.
271    #[must_use]
272    pub fn with_evidence(mut self, evidence: EvidenceRef) -> Self {
273        self.evidence.push(evidence);
274        self
275    }
276
277    /// Returns the verification outcome.
278    #[must_use]
279    pub const fn status(&self) -> VerificationStatus {
280        self.status
281    }
282
283    /// Returns verification completion time.
284    #[must_use]
285    pub const fn completed_at(&self) -> Timestamp {
286        self.completed_at
287    }
288
289    /// Returns verification evidence.
290    #[must_use]
291    pub fn evidence(&self) -> &[EvidenceRef] {
292        &self.evidence
293    }
294}
295
296/// Timing, mutation-send, and retry metadata for terminal execution.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299pub struct ExecutionMetadata {
300    started_at: Timestamp,
301    completed_at: Timestamp,
302    mutation_send_state: MutationSendState,
303    retry: RetryClass,
304}
305
306impl ExecutionMetadata {
307    /// Creates validated execution metadata.
308    pub fn new(
309        started_at: Timestamp,
310        completed_at: Timestamp,
311        mutation_send_state: MutationSendState,
312        retry: RetryClass,
313    ) -> Result<Self, ResultError> {
314        if completed_at < started_at {
315            return Err(ResultError::CompletionBeforeStart);
316        }
317        Ok(Self {
318            started_at,
319            completed_at,
320            mutation_send_state,
321            retry,
322        })
323    }
324
325    /// Returns execution start time.
326    #[must_use]
327    pub const fn started_at(self) -> Timestamp {
328        self.started_at
329    }
330
331    /// Returns execution completion time.
332    #[must_use]
333    pub const fn completed_at(self) -> Timestamp {
334        self.completed_at
335    }
336
337    /// Returns whether a mutation may have reached the target.
338    #[must_use]
339    pub const fn mutation_send_state(self) -> MutationSendState {
340        self.mutation_send_state
341    }
342
343    /// Returns retry classification.
344    #[must_use]
345    pub const fn retry(self) -> RetryClass {
346        self.retry
347    }
348}
349
350/// Terminal result for one operation execution.
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
353pub struct OperationResult {
354    operation_id: OperationId,
355    operation: OperationName,
356    target: TargetRef,
357    status: OperationStatus,
358    started_at: Timestamp,
359    completed_at: Timestamp,
360    mutation_send_state: MutationSendState,
361    retry: RetryClass,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    output: Option<Value>,
364    artifacts: Vec<ArtifactRef>,
365    diagnostics: Vec<Diagnostic>,
366    evidence: Vec<EvidenceRef>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    verification: Option<VerificationResult>,
369    #[serde(default)]
370    redaction: RedactionMetadata,
371}
372
373impl OperationResult {
374    /// Creates a validated terminal result.
375    pub fn new(
376        operation_id: OperationId,
377        operation: OperationName,
378        target: TargetRef,
379        status: OperationStatus,
380        execution: ExecutionMetadata,
381    ) -> Result<Self, ResultError> {
382        if status == OperationStatus::Succeeded && execution.retry() != RetryClass::Never {
383            return Err(ResultError::RetryOnSuccess);
384        }
385        Ok(Self {
386            operation_id,
387            operation,
388            target,
389            status,
390            started_at: execution.started_at(),
391            completed_at: execution.completed_at(),
392            mutation_send_state: execution.mutation_send_state(),
393            retry: execution.retry(),
394            output: None,
395            artifacts: Vec::new(),
396            diagnostics: Vec::new(),
397            evidence: Vec::new(),
398            verification: None,
399            redaction: RedactionMetadata::none(),
400        })
401    }
402
403    /// Adds bounded structured output.
404    pub fn with_output(mut self, output: Value) -> Result<Self, ResultError> {
405        const MAX_INLINE_BYTES: usize = 256 * 1_024;
406        let encoded = serde_json::to_vec(&output)
407            .map_err(|error| ResultError::OutputSerialization(error.to_string()))?;
408        if encoded.len() > MAX_INLINE_BYTES {
409            return Err(ResultError::InlineOutputTooLarge {
410                bytes: encoded.len(),
411                max_bytes: MAX_INLINE_BYTES,
412            });
413        }
414        self.output = Some(output);
415        Ok(self)
416    }
417
418    /// Adds an artifact reference.
419    #[must_use]
420    pub fn with_artifact(mut self, artifact: ArtifactRef) -> Self {
421        self.artifacts.push(artifact);
422        self
423    }
424
425    /// Adds a structured diagnostic.
426    #[must_use]
427    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
428        self.diagnostics.push(diagnostic);
429        self
430    }
431
432    /// Adds captured evidence.
433    #[must_use]
434    pub fn with_evidence(mut self, evidence: EvidenceRef) -> Self {
435        self.evidence.push(evidence);
436        self
437    }
438
439    /// Adds an independent verification result.
440    pub fn with_verification(
441        mut self,
442        verification: VerificationResult,
443    ) -> Result<Self, ResultError> {
444        if verification.completed_at() < self.completed_at {
445            return Err(ResultError::VerificationBeforeCompletion);
446        }
447        self.verification = Some(verification);
448        Ok(self)
449    }
450
451    /// Adds redaction metadata.
452    #[must_use]
453    pub fn with_redaction(mut self, redaction: RedactionMetadata) -> Self {
454        self.redaction = redaction;
455        self
456    }
457
458    /// Validates terminal-status diagnostics and verification invariants.
459    pub fn validate(&self) -> Result<(), ResultError> {
460        if self.status != OperationStatus::Succeeded
461            && !self
462                .diagnostics
463                .iter()
464                .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
465        {
466            return Err(ResultError::FailureWithoutErrorDiagnostic);
467        }
468        if self.status != OperationStatus::Succeeded
469            && self
470                .verification
471                .as_ref()
472                .is_some_and(|verification| verification.status() == VerificationStatus::Verified)
473        {
474            return Err(ResultError::FailedOperationVerified);
475        }
476        Ok(())
477    }
478
479    /// Returns the operation identity.
480    #[must_use]
481    pub fn operation_id(&self) -> &OperationId {
482        &self.operation_id
483    }
484
485    /// Returns the canonical operation name.
486    #[must_use]
487    pub fn operation(&self) -> &OperationName {
488        &self.operation
489    }
490
491    /// Returns the resolved target.
492    #[must_use]
493    pub fn target(&self) -> &TargetRef {
494        &self.target
495    }
496
497    /// Returns execution status.
498    #[must_use]
499    pub const fn status(&self) -> OperationStatus {
500        self.status
501    }
502
503    /// Returns mutation-send state.
504    #[must_use]
505    pub const fn mutation_send_state(&self) -> MutationSendState {
506        self.mutation_send_state
507    }
508
509    /// Returns retry classification.
510    #[must_use]
511    pub const fn retry(&self) -> RetryClass {
512        self.retry
513    }
514
515    /// Returns inline output when present.
516    #[must_use]
517    pub fn output(&self) -> Option<&Value> {
518        self.output.as_ref()
519    }
520
521    /// Returns artifact references.
522    #[must_use]
523    pub fn artifacts(&self) -> &[ArtifactRef] {
524        &self.artifacts
525    }
526
527    /// Returns diagnostics.
528    #[must_use]
529    pub fn diagnostics(&self) -> &[Diagnostic] {
530        &self.diagnostics
531    }
532
533    /// Returns captured evidence references.
534    #[must_use]
535    pub fn evidence(&self) -> &[EvidenceRef] {
536        &self.evidence
537    }
538
539    /// Returns verification when performed.
540    #[must_use]
541    pub fn verification(&self) -> Option<&VerificationResult> {
542        self.verification.as_ref()
543    }
544
545    /// Returns redaction metadata.
546    #[must_use]
547    pub fn redaction(&self) -> &RedactionMetadata {
548        &self.redaction
549    }
550}
551
552/// Invalid operation result or result metadata.
553#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
554#[non_exhaustive]
555pub enum ResultError {
556    /// Completion preceded start time.
557    #[error("operation completion cannot precede start")]
558    CompletionBeforeStart,
559    /// Successful results must not recommend retry.
560    #[error("successful operations cannot carry retry advice")]
561    RetryOnSuccess,
562    /// Failed and cancelled results require an error diagnostic.
563    #[error("failed or cancelled operation requires an error diagnostic")]
564    FailureWithoutErrorDiagnostic,
565    /// A failed or cancelled operation cannot be independently verified successful.
566    #[error("failed or cancelled operation cannot be verified successful")]
567    FailedOperationVerified,
568    /// Verification completion preceded operation completion.
569    #[error("verification cannot complete before operation execution")]
570    VerificationBeforeCompletion,
571    /// Inline output exceeded the bounded response budget.
572    #[error("inline output is {bytes} bytes; maximum is {max_bytes}; use an artifact")]
573    InlineOutputTooLarge {
574        /// Encoded output size.
575        bytes: usize,
576        /// Maximum inline output size.
577        max_bytes: usize,
578    },
579    /// Inline output could not be serialized.
580    #[error("could not serialize inline output: {0}")]
581    OutputSerialization(String),
582    /// Text was empty, oversized, or contained control characters.
583    #[error("invalid {field}: expected 1..={max_chars} non-control characters")]
584    InvalidText {
585        /// Field name.
586        field: &'static str,
587        /// Maximum accepted character count.
588        max_chars: usize,
589    },
590    /// Stable code was invalid.
591    #[error("invalid result code: {0}")]
592    InvalidCode(String),
593    /// SHA-256 digest was invalid.
594    #[error("invalid SHA-256 digest")]
595    InvalidSha256,
596}
597
598fn validate_text(field: &'static str, value: &str, max_chars: usize) -> Result<(), ResultError> {
599    let chars = value.chars().count();
600    if chars == 0 || chars > max_chars || value.chars().any(char::is_control) {
601        return Err(ResultError::InvalidText { field, max_chars });
602    }
603    Ok(())
604}
605
606fn validate_code(value: &str) -> Result<(), ResultError> {
607    let mut chars = value.chars();
608    if !matches!(chars.next(), Some('a'..='z'))
609        || !chars.all(|character| {
610            character.is_ascii_lowercase()
611                || character.is_ascii_digit()
612                || matches!(character, '.' | '_' | '-')
613        })
614        || value.ends_with(['.', '_', '-'])
615    {
616        return Err(ResultError::InvalidCode(value.to_owned()));
617    }
618    Ok(())
619}
620
621fn validate_sha256(value: &str) -> Result<(), ResultError> {
622    if value.len() == 64
623        && value
624            .bytes()
625            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
626    {
627        Ok(())
628    } else {
629        Err(ResultError::InvalidSha256)
630    }
631}
632
633#[cfg(test)]
634#[path = "result_tests.rs"]
635mod tests;