1use naoutbox_core::OutboxEvent;
11use nasaga_core::{
12 AttemptNo, CommandId, DefinitionVersion, EffectId, SagaId, StepAttemptStatus,
13 StepForwardStatus, StepName, StepPhase, TenantId, WorkflowName,
14};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18pub const COMMAND_EVENT_TYPE: &str = "saga.command";
20
21pub const RESULT_EVENT_TYPE: &str = "saga.result";
23
24pub const SAGA_AGGREGATE_TYPE: &str = "saga";
27
28const REASON_CODE_MAX_LEN: usize = 64;
31
32pub(crate) fn valid_reason_code(code: &str) -> bool {
39 !code.is_empty()
40 && code.len() <= REASON_CODE_MAX_LEN
41 && code
42 .bytes()
43 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
44}
45
46const RESULT_EVENT_NAMESPACE: Uuid = Uuid::from_bytes(*b"nasasaga-v1-rsns");
51
52#[doc(hidden)]
59pub fn canonical_bytes(fields: &[&[u8]]) -> Vec<u8> {
60 let mut encoded = Vec::new();
61 for field in fields {
62 encoded.extend_from_slice(&(field.len() as u64).to_be_bytes());
63 encoded.extend_from_slice(field);
64 }
65 encoded
66}
67
68pub fn derive_result_event_id(command_id: &str) -> String {
75 Uuid::new_v5(
76 &RESULT_EVENT_NAMESPACE,
77 &canonical_bytes(&[command_id.as_bytes(), b"result"]),
78 )
79 .to_string()
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SagaCommandEnvelope {
88 pub saga_id: String,
90 pub tenant_id: String,
92 pub workflow: String,
94 pub definition_version: u32,
96 pub definition_digest: String,
98 pub step: String,
100 pub phase: String,
102 pub attempt: u32,
104 pub effect_id: String,
106 pub command_id: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub recovery_operation_id: Option<String>,
115 pub payload: Option<serde_json::Value>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub raw_payload: Option<nasaga_core::SagaPayload>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SagaResultEnvelope {
128 pub event_id: String,
130 pub saga_id: String,
132 pub tenant_id: String,
134 pub workflow: String,
136 pub definition_version: u32,
138 pub definition_digest: String,
140 pub step: String,
142 pub phase: String,
144 pub attempt: u32,
146 pub effect_id: String,
148 pub command_id: String,
150 pub status: String,
152 pub terminal_status: Option<String>,
154 pub reason_code: Option<String>,
156}
157
158#[derive(Debug, Clone)]
160pub struct VerifiedIdentity {
161 pub saga_id: SagaId,
163 pub tenant: TenantId,
165 pub workflow: WorkflowName,
167 pub definition_version: DefinitionVersion,
169 pub definition_digest: String,
171 pub step: StepName,
173 pub phase: StepPhase,
175 pub attempt: AttemptNo,
177 pub effect: EffectId,
179 pub command: CommandId,
181}
182
183#[allow(clippy::too_many_arguments)]
195fn verify_identity(
196 saga_id: &str,
197 tenant_id: &str,
198 workflow: &str,
199 definition_version: u32,
200 definition_digest: &str,
201 step: &str,
202 phase: &str,
203 attempt: u32,
204 effect_id: &str,
205 command_id: &str,
206) -> anyhow::Result<VerifiedIdentity> {
207 let saga_id = SagaId::new(saga_id).map_err(|v| anyhow::anyhow!("bad saga id: {}", v.code()))?;
208 let tenant =
209 TenantId::new(tenant_id).map_err(|v| anyhow::anyhow!("bad tenant: {}", v.code()))?;
210 let workflow =
211 WorkflowName::new(workflow).map_err(|v| anyhow::anyhow!("bad workflow: {}", v.code()))?;
212 let version = DefinitionVersion::new(definition_version)
213 .map_err(|v| anyhow::anyhow!("bad version: {}", v.code()))?;
214 let step = StepName::new(step).map_err(|v| anyhow::anyhow!("bad step: {}", v.code()))?;
215 let phase = StepPhase::parse(phase).ok_or_else(|| anyhow::anyhow!("bad phase"))?;
216 let attempt =
217 AttemptNo::new(attempt).map_err(|v| anyhow::anyhow!("bad attempt: {}", v.code()))?;
218 if definition_digest.len() != 64
221 || !definition_digest
222 .bytes()
223 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
224 {
225 anyhow::bail!("bad definition digest: expected 64 lowercase hex characters");
226 }
227 let effect = EffectId::derive(&saga_id, version, &step, phase);
228 let command = CommandId::derive(&effect, attempt);
229 if effect.to_string() != effect_id || command.to_string() != command_id {
232 anyhow::bail!("envelope identity mismatch: derived ids differ from self-reported ids");
233 }
234 Ok(VerifiedIdentity {
235 saga_id,
236 tenant,
237 workflow,
238 definition_version: version,
239 definition_digest: definition_digest.to_string(),
240 step,
241 phase,
242 attempt,
243 effect,
244 command,
245 })
246}
247
248fn verify_result_contract(
257 envelope: &SagaResultEnvelope,
258 identity: &VerifiedIdentity,
259) -> anyhow::Result<()> {
260 if envelope.event_id != derive_result_event_id(&envelope.command_id) {
261 anyhow::bail!("result event identity does not match command identity");
262 }
263 let status = envelope.parsed_status()?;
264 let phase_allows_status = match identity.phase {
265 StepPhase::Execute => matches!(
266 status,
267 StepAttemptStatus::Succeeded
268 | StepAttemptStatus::Rejected
269 | StepAttemptStatus::Unknown
270 | StepAttemptStatus::Halted
271 ),
272 StepPhase::Cancel => matches!(
273 status,
274 StepAttemptStatus::CancelConfirmed
275 | StepAttemptStatus::AlreadyTerminal
276 | StepAttemptStatus::ResolutionPending
277 ),
278 StepPhase::Compensate => matches!(
279 status,
280 StepAttemptStatus::Succeeded | StepAttemptStatus::Unknown | StepAttemptStatus::Halted
281 ),
282 StepPhase::Resolve => matches!(
283 status,
284 StepAttemptStatus::Succeeded
285 | StepAttemptStatus::Rejected
286 | StepAttemptStatus::Unknown
287 | StepAttemptStatus::Halted
288 ),
289 };
290 if !phase_allows_status {
291 anyhow::bail!("result status is not valid for its phase");
292 }
293
294 match (status, envelope.parsed_terminal()?) {
295 (
296 StepAttemptStatus::AlreadyTerminal,
297 Some(
298 StepForwardStatus::Succeeded
299 | StepForwardStatus::Rejected
300 | StepForwardStatus::Halted,
301 ),
302 ) => {}
303 (StepAttemptStatus::AlreadyTerminal, _) => {
304 anyhow::bail!("ALREADY_TERMINAL requires a settled terminal forward status")
305 }
306 (_, Some(_)) => anyhow::bail!("terminal_status is only valid for ALREADY_TERMINAL"),
307 (_, None) => {}
308 }
309
310 if let Some(code) = envelope.reason_code.as_deref() {
311 if !valid_reason_code(code) {
312 anyhow::bail!("reason_code is not a stable bounded identifier");
313 }
314 }
315 Ok(())
316}
317
318impl SagaCommandEnvelope {
319 pub fn business_payload(&self) -> anyhow::Result<Option<nasaga_core::SagaPayload>> {
325 if self.payload.is_some() && self.raw_payload.is_some() {
326 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
327 }
328 let payload = self
329 .raw_payload
330 .clone()
331 .or_else(|| self.payload.clone().map(nasaga_core::SagaPayload::json));
332 if let Some(payload) = &payload {
333 payload
334 .validate()
335 .map_err(|_| crate::SagaCommandProcessingError::ContractInvalid)?;
336 }
337 Ok(payload)
338 }
339
340 pub fn verify_payload_contract(
346 &self,
347 content_type: &str,
348 schema_id: &str,
349 ) -> anyhow::Result<()> {
350 if let Some(payload) = self.business_payload()? {
351 if payload.content_type != content_type || payload.schema_id != schema_id {
352 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
353 }
354 }
355 Ok(())
356 }
357
358 pub fn decode_payload<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<T> {
364 let result = match self.business_payload()? {
365 None => serde_json::from_value(serde_json::Value::Null),
366 Some(payload) if payload.content_type == "application/json" => {
367 serde_json::from_slice(&payload.body)
368 }
369 Some(payload) => serde_json::from_value(serde_json::json!(payload.body)),
370 };
371 result.map_err(|_| crate::SagaCommandProcessingError::ContractInvalid.into())
372 }
373
374 pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
380 let identity = verify_identity(
381 &self.saga_id,
382 &self.tenant_id,
383 &self.workflow,
384 self.definition_version,
385 &self.definition_digest,
386 &self.step,
387 &self.phase,
388 self.attempt,
389 &self.effect_id,
390 &self.command_id,
391 )?;
392 if let Some(operation_id) = self.recovery_operation_id.as_deref() {
393 if operation_id.is_empty()
394 || operation_id.trim() != operation_id
395 || operation_id.len() > 190
396 || operation_id.chars().any(char::is_control)
397 || !matches!(identity.phase, StepPhase::Compensate | StepPhase::Resolve)
398 {
399 anyhow::bail!("recovery operation identity is invalid for the command phase");
400 }
401 }
402 Ok(identity)
403 }
404
405 pub fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
412 self.verified()
413 .map_err(|_| crate::SagaCommandProcessingError::IdentityInvalid.into())
414 }
415
416 pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
425 self.verified()?;
428 Ok(OutboxEvent {
429 event_id: self.command_id.clone(),
430 aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
431 aggregate_id: self.saga_id.clone(),
432 event_type: COMMAND_EVENT_TYPE.to_string(),
433 payload: serde_json::to_vec(self)?,
434 traceparent: None,
435 tenant: self.tenant_id.clone(),
438 })
439 }
440}
441
442impl SagaResultEnvelope {
443 pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
449 let identity = verify_identity(
450 &self.saga_id,
451 &self.tenant_id,
452 &self.workflow,
453 self.definition_version,
454 &self.definition_digest,
455 &self.step,
456 &self.phase,
457 self.attempt,
458 &self.effect_id,
459 &self.command_id,
460 )?;
461 verify_result_contract(self, &identity)?;
462 Ok(identity)
463 }
464
465 pub(crate) fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
472 let identity = verify_identity(
473 &self.saga_id,
474 &self.tenant_id,
475 &self.workflow,
476 self.definition_version,
477 &self.definition_digest,
478 &self.step,
479 &self.phase,
480 self.attempt,
481 &self.effect_id,
482 &self.command_id,
483 )
484 .map_err(|_| crate::SagaResultProcessingError::IdentityInvalid)?;
485 verify_result_contract(self, &identity)
486 .map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
487 Ok(identity)
488 }
489
490 pub fn parsed_status(&self) -> anyhow::Result<StepAttemptStatus> {
496 let status = StepAttemptStatus::parse(&self.status)
497 .ok_or_else(|| anyhow::anyhow!("unknown result status"))?;
498 if status.is_in_flight() {
499 anyhow::bail!("result status must be settled");
500 }
501 Ok(status)
502 }
503
504 pub fn parsed_terminal(&self) -> anyhow::Result<Option<StepForwardStatus>> {
510 self.terminal_status
511 .as_deref()
512 .map(|raw| {
513 StepForwardStatus::parse(raw)
514 .ok_or_else(|| anyhow::anyhow!("unknown terminal status"))
515 })
516 .transpose()
517 }
518
519 pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
525 self.verified()?;
528 Ok(OutboxEvent {
529 event_id: self.event_id.clone(),
530 aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
531 aggregate_id: self.saga_id.clone(),
532 event_type: RESULT_EVENT_TYPE.to_string(),
533 payload: serde_json::to_vec(self)?,
534 traceparent: None,
535 tenant: self.tenant_id.clone(),
536 })
537 }
538}