Skip to main content

soma_ops/
request.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{
4    AccessClass, ActorRef, AuthorizationId, CorrelationId, IdempotencyKey, OperationDefinition,
5    OperationId, OperationName, OperationSpec, PlanFingerprint, ProducerRef, SpecError, TargetKind,
6    TargetRef, TargetRefError, Timestamp, TraceContext,
7};
8
9const MAX_CONFIRMATION_REF_CHARS: usize = 256;
10
11/// Traceable execution context supplied by the calling product.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct OperationContext {
15    operation_id: OperationId,
16    correlation_id: CorrelationId,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    causation_id: Option<OperationId>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    actor: Option<ActorRef>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    idempotency_key: Option<IdempotencyKey>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    deadline: Option<Timestamp>,
25    #[serde(default)]
26    trace: TraceContext,
27}
28
29impl OperationContext {
30    /// Creates a context with fresh operation and correlation identities.
31    #[must_use]
32    pub fn new() -> Self {
33        Self {
34            operation_id: OperationId::new(),
35            correlation_id: CorrelationId::new(),
36            causation_id: None,
37            actor: None,
38            idempotency_key: None,
39            deadline: None,
40            trace: TraceContext::default(),
41        }
42    }
43
44    /// Uses an existing workflow correlation identity.
45    #[must_use]
46    pub fn with_correlation_id(mut self, correlation_id: CorrelationId) -> Self {
47        self.correlation_id = correlation_id;
48        self
49    }
50
51    /// Records the operation that caused this operation.
52    #[must_use]
53    pub fn with_causation_id(mut self, causation_id: OperationId) -> Self {
54        self.causation_id = Some(causation_id);
55        self
56    }
57
58    /// Records the requesting actor.
59    #[must_use]
60    pub fn with_actor(mut self, actor: ActorRef) -> Self {
61        self.actor = Some(actor);
62        self
63    }
64
65    /// Adds a caller-provided idempotency key.
66    #[must_use]
67    pub fn with_idempotency_key(mut self, idempotency_key: IdempotencyKey) -> Self {
68        self.idempotency_key = Some(idempotency_key);
69        self
70    }
71
72    /// Sets an absolute deadline.
73    #[must_use]
74    pub fn with_deadline(mut self, deadline: Timestamp) -> Self {
75        self.deadline = Some(deadline);
76        self
77    }
78
79    /// Adds trace context.
80    #[must_use]
81    pub fn with_trace(mut self, trace: TraceContext) -> Self {
82        self.trace = trace;
83        self
84    }
85
86    /// Returns the execution identity.
87    #[must_use]
88    pub fn operation_id(&self) -> &OperationId {
89        &self.operation_id
90    }
91
92    /// Returns the workflow correlation identity.
93    #[must_use]
94    pub fn correlation_id(&self) -> &CorrelationId {
95        &self.correlation_id
96    }
97
98    /// Returns the causal operation when present.
99    #[must_use]
100    pub fn causation_id(&self) -> Option<&OperationId> {
101        self.causation_id.as_ref()
102    }
103
104    /// Returns the requesting actor when known.
105    #[must_use]
106    pub fn actor(&self) -> Option<&ActorRef> {
107        self.actor.as_ref()
108    }
109
110    /// Returns the idempotency key when present.
111    #[must_use]
112    pub fn idempotency_key(&self) -> Option<&IdempotencyKey> {
113        self.idempotency_key.as_ref()
114    }
115
116    /// Returns the absolute deadline when present.
117    #[must_use]
118    pub const fn deadline(&self) -> Option<Timestamp> {
119        self.deadline
120    }
121
122    /// Returns propagated trace context.
123    #[must_use]
124    pub fn trace(&self) -> &TraceContext {
125        &self.trace
126    }
127}
128
129impl Default for OperationContext {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// Exact operation and target scope approved by product policy.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct AuthorizationScope {
139    operation: OperationName,
140    target: TargetRef,
141}
142
143impl AuthorizationScope {
144    /// Creates an exact authorization scope.
145    #[must_use]
146    pub const fn new(operation: OperationName, target: TargetRef) -> Self {
147        Self { operation, target }
148    }
149
150    /// Returns the authorized operation.
151    #[must_use]
152    pub fn operation(&self) -> &OperationName {
153        &self.operation
154    }
155
156    /// Returns the authorized target.
157    #[must_use]
158    pub fn target(&self) -> &TargetRef {
159        &self.target
160    }
161}
162
163/// Opaque, time-bounded authorization evidence issued by a product layer.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166pub struct AuthorizationEvidence {
167    id: AuthorizationId,
168    issuer: ProducerRef,
169    scope: AuthorizationScope,
170    issued_at: Timestamp,
171    expires_at: Timestamp,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    plan_fingerprint: Option<PlanFingerprint>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    confirmation_ref: Option<String>,
176}
177
178impl AuthorizationEvidence {
179    /// Creates time-bounded authorization evidence.
180    pub fn new(
181        issuer: ProducerRef,
182        scope: AuthorizationScope,
183        issued_at: Timestamp,
184        expires_at: Timestamp,
185    ) -> Result<Self, AuthorizationError> {
186        if expires_at <= issued_at {
187            return Err(AuthorizationError::InvalidLifetime);
188        }
189        Ok(Self {
190            id: AuthorizationId::new(),
191            issuer,
192            scope,
193            issued_at,
194            expires_at,
195            plan_fingerprint: None,
196            confirmation_ref: None,
197        })
198    }
199
200    /// Binds authorization to an immutable plan fingerprint.
201    #[must_use]
202    pub fn with_plan_fingerprint(mut self, fingerprint: PlanFingerprint) -> Self {
203        self.plan_fingerprint = Some(fingerprint);
204        self
205    }
206
207    /// Records an opaque human-confirmation reference.
208    pub fn with_confirmation_ref(
209        mut self,
210        confirmation_ref: impl Into<String>,
211    ) -> Result<Self, AuthorizationError> {
212        let confirmation_ref = confirmation_ref.into();
213        let chars = confirmation_ref.chars().count();
214        if chars == 0
215            || chars > MAX_CONFIRMATION_REF_CHARS
216            || confirmation_ref.chars().any(char::is_control)
217        {
218            return Err(AuthorizationError::InvalidConfirmationRef);
219        }
220        self.confirmation_ref = Some(confirmation_ref);
221        Ok(self)
222    }
223
224    /// Validates expiration and exact operation, target, and plan binding.
225    pub fn validate_binding(
226        &self,
227        operation: &OperationName,
228        target: &TargetRef,
229        now: Timestamp,
230        expected_plan: Option<&PlanFingerprint>,
231    ) -> Result<(), AuthorizationError> {
232        if now < self.issued_at {
233            return Err(AuthorizationError::NotYetValid);
234        }
235        if now >= self.expires_at {
236            return Err(AuthorizationError::Expired);
237        }
238        if self.scope.operation() != operation {
239            return Err(AuthorizationError::OperationMismatch);
240        }
241        if self.scope.target() != target {
242            return Err(AuthorizationError::TargetMismatch);
243        }
244        if self.plan_fingerprint.as_ref() != expected_plan {
245            return Err(AuthorizationError::PlanMismatch);
246        }
247        Ok(())
248    }
249
250    /// Returns the evidence identity.
251    #[must_use]
252    pub fn id(&self) -> &AuthorizationId {
253        &self.id
254    }
255
256    /// Returns the issuing product or policy component.
257    #[must_use]
258    pub fn issuer(&self) -> &ProducerRef {
259        &self.issuer
260    }
261
262    /// Returns the exact approved scope.
263    #[must_use]
264    pub fn scope(&self) -> &AuthorizationScope {
265        &self.scope
266    }
267
268    /// Returns the authorization expiry.
269    #[must_use]
270    pub const fn expires_at(&self) -> Timestamp {
271        self.expires_at
272    }
273
274    /// Returns the bound plan fingerprint when present.
275    #[must_use]
276    pub fn plan_fingerprint(&self) -> Option<&PlanFingerprint> {
277        self.plan_fingerprint.as_ref()
278    }
279
280    /// Returns the opaque confirmation reference when present.
281    #[must_use]
282    pub fn confirmation_ref(&self) -> Option<&str> {
283        self.confirmation_ref.as_deref()
284    }
285}
286
287/// Typed request envelope for one concrete operation definition.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290pub struct OperationRequest<P> {
291    context: OperationContext,
292    operation: OperationName,
293    target: TargetRef,
294    parameters: P,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    authorization: Option<AuthorizationEvidence>,
297}
298
299impl<P> OperationRequest<P> {
300    /// Builds a request from a typed operation definition.
301    pub fn new<O>(context: OperationContext, parameters: P) -> Result<Self, TargetRefError>
302    where
303        O: OperationDefinition<Parameters = P>,
304    {
305        let spec = O::spec();
306        let target = O::target(&parameters)?;
307        Ok(Self {
308            context,
309            operation: spec.name().clone(),
310            target,
311            parameters,
312            authorization: None,
313        })
314    }
315
316    /// Adds product-issued authorization evidence.
317    #[must_use]
318    pub fn with_authorization(mut self, authorization: AuthorizationEvidence) -> Self {
319        self.authorization = Some(authorization);
320        self
321    }
322
323    /// Validates the request against catalog and authorization metadata.
324    pub fn validate_against(
325        &self,
326        spec: &OperationSpec,
327        now: Timestamp,
328        expected_plan: Option<&PlanFingerprint>,
329    ) -> Result<(), RequestError> {
330        spec.validate()?;
331        if &self.operation != spec.name() {
332            return Err(RequestError::OperationMismatch);
333        }
334        if self.target.kind() != spec.target_kind() {
335            return Err(RequestError::TargetKindMismatch {
336                expected: spec.target_kind().clone(),
337                actual: self.target.kind().clone(),
338            });
339        }
340        if self
341            .context
342            .deadline()
343            .is_some_and(|deadline| deadline <= now)
344        {
345            return Err(RequestError::DeadlineExceeded);
346        }
347        if spec.access() == AccessClass::Mutation
348            && spec.idempotent()
349            && self.context.idempotency_key().is_none()
350        {
351            return Err(RequestError::MissingIdempotencyKey);
352        }
353        match (&self.authorization, spec.access()) {
354            (None, AccessClass::Mutation) => Err(RequestError::MissingAuthorization),
355            (Some(authorization), _) => authorization
356                .validate_binding(&self.operation, &self.target, now, expected_plan)
357                .map_err(RequestError::Authorization),
358            (None, AccessClass::Read) => Ok(()),
359        }
360    }
361
362    /// Returns execution context.
363    #[must_use]
364    pub fn context(&self) -> &OperationContext {
365        &self.context
366    }
367
368    /// Returns the canonical operation name.
369    #[must_use]
370    pub fn operation(&self) -> &OperationName {
371        &self.operation
372    }
373
374    /// Returns the resolved target.
375    #[must_use]
376    pub fn target(&self) -> &TargetRef {
377        &self.target
378    }
379
380    /// Returns typed operation parameters.
381    #[must_use]
382    pub fn parameters(&self) -> &P {
383        &self.parameters
384    }
385
386    /// Returns authorization evidence when supplied.
387    #[must_use]
388    pub fn authorization(&self) -> Option<&AuthorizationEvidence> {
389        self.authorization.as_ref()
390    }
391}
392
393/// Authorization evidence validation failure.
394#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
395#[non_exhaustive]
396pub enum AuthorizationError {
397    /// Expiry must be later than issue time.
398    #[error("authorization expiry must be later than issue time")]
399    InvalidLifetime,
400    /// Authorization issue time is in the future.
401    #[error("authorization is not yet valid")]
402    NotYetValid,
403    /// Authorization has expired.
404    #[error("authorization has expired")]
405    Expired,
406    /// The operation does not match the approved scope.
407    #[error("authorization operation does not match request")]
408    OperationMismatch,
409    /// The target does not match the approved scope.
410    #[error("authorization target does not match request")]
411    TargetMismatch,
412    /// Plan binding differs, including missing versus present binding.
413    #[error("authorization plan fingerprint does not match request")]
414    PlanMismatch,
415    /// The confirmation reference was empty, oversized, or contained control characters.
416    #[error("invalid authorization confirmation reference")]
417    InvalidConfirmationRef,
418}
419
420/// Request validation failure.
421#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
422#[non_exhaustive]
423pub enum RequestError {
424    /// Operation specification is invalid.
425    #[error(transparent)]
426    Spec(#[from] SpecError),
427    /// Request operation differs from the supplied specification.
428    #[error("request operation does not match specification")]
429    OperationMismatch,
430    /// Target kind differs from the supplied specification.
431    #[error("request target kind mismatch: expected {expected:?}, found {actual:?}")]
432    TargetKindMismatch {
433        /// Expected target kind.
434        expected: TargetKind,
435        /// Actual target kind.
436        actual: TargetKind,
437    },
438    /// Absolute deadline has passed.
439    #[error("operation deadline has passed")]
440    DeadlineExceeded,
441    /// An idempotent mutation omitted its idempotency key.
442    #[error("idempotent mutation requires an idempotency key")]
443    MissingIdempotencyKey,
444    /// A mutation omitted authorization evidence.
445    #[error("mutation requires authorization evidence")]
446    MissingAuthorization,
447    /// Authorization evidence is invalid for the request.
448    #[error(transparent)]
449    Authorization(#[from] AuthorizationError),
450}
451
452#[cfg(test)]
453#[path = "request_tests.rs"]
454mod tests;