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)]
199fn verify_identity(
200 saga_id: &str,
201 tenant_id: &str,
202 workflow: &str,
203 definition_version: u32,
204 definition_digest: &str,
205 step: &str,
206 phase: &str,
207 attempt: u32,
208 effect_id: &str,
209 command_id: &str,
210) -> anyhow::Result<VerifiedIdentity> {
211 let saga_id = SagaId::new(saga_id).map_err(|v| anyhow::anyhow!("bad saga id: {}", v.code()))?;
212 let tenant =
213 TenantId::new(tenant_id).map_err(|v| anyhow::anyhow!("bad tenant: {}", v.code()))?;
214 let workflow =
215 WorkflowName::new(workflow).map_err(|v| anyhow::anyhow!("bad workflow: {}", v.code()))?;
216 let version = DefinitionVersion::new(definition_version)
217 .map_err(|v| anyhow::anyhow!("bad version: {}", v.code()))?;
218 let step = StepName::new(step).map_err(|v| anyhow::anyhow!("bad step: {}", v.code()))?;
219 let phase = StepPhase::parse(phase).ok_or_else(|| anyhow::anyhow!("bad phase"))?;
220 let attempt =
221 AttemptNo::new(attempt).map_err(|v| anyhow::anyhow!("bad attempt: {}", v.code()))?;
222 if definition_digest.len() != 64
225 || !definition_digest
226 .bytes()
227 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
228 {
229 anyhow::bail!("bad definition digest: expected 64 lowercase hex characters");
230 }
231 let effect = EffectId::derive(&saga_id, version, &step, phase);
232 let command = CommandId::derive(&effect, attempt);
233 if effect.to_string() != effect_id || command.to_string() != command_id {
236 anyhow::bail!("envelope identity mismatch: derived ids differ from self-reported ids");
237 }
238 Ok(VerifiedIdentity {
239 saga_id,
240 tenant,
241 workflow,
242 definition_version: version,
243 definition_digest: definition_digest.to_string(),
244 step,
245 phase,
246 attempt,
247 effect,
248 command,
249 })
250}
251
252fn verify_result_contract(
261 envelope: &SagaResultEnvelope,
262 identity: &VerifiedIdentity,
263) -> anyhow::Result<()> {
264 if envelope.event_id != derive_result_event_id(&envelope.command_id) {
265 anyhow::bail!("result event identity does not match command identity");
266 }
267 let status = envelope.parsed_status()?;
268 let phase_allows_status = match identity.phase {
269 StepPhase::Execute => matches!(
270 status,
271 StepAttemptStatus::Succeeded
272 | StepAttemptStatus::Rejected
273 | StepAttemptStatus::Unknown
274 | StepAttemptStatus::Halted
275 ),
276 StepPhase::Cancel => matches!(
277 status,
278 StepAttemptStatus::CancelConfirmed
279 | StepAttemptStatus::AlreadyTerminal
280 | StepAttemptStatus::ResolutionPending
281 ),
282 StepPhase::Compensate => matches!(
283 status,
284 StepAttemptStatus::Succeeded | StepAttemptStatus::Unknown | StepAttemptStatus::Halted
285 ),
286 StepPhase::Resolve => matches!(
287 status,
288 StepAttemptStatus::Succeeded
289 | StepAttemptStatus::Rejected
290 | StepAttemptStatus::Unknown
291 | StepAttemptStatus::Halted
292 ),
293 };
294 if !phase_allows_status {
295 anyhow::bail!("result status is not valid for its phase");
296 }
297
298 match (status, envelope.parsed_terminal()?) {
299 (
300 StepAttemptStatus::AlreadyTerminal,
301 Some(
302 StepForwardStatus::Succeeded
303 | StepForwardStatus::Rejected
304 | StepForwardStatus::Halted,
305 ),
306 ) => {}
307 (StepAttemptStatus::AlreadyTerminal, _) => {
308 anyhow::bail!("ALREADY_TERMINAL requires a settled terminal forward status")
309 }
310 (_, Some(_)) => anyhow::bail!("terminal_status is only valid for ALREADY_TERMINAL"),
311 (_, None) => {}
312 }
313
314 if let Some(code) = envelope.reason_code.as_deref() {
315 if !valid_reason_code(code) {
316 anyhow::bail!("reason_code is not a stable bounded identifier");
317 }
318 }
319 Ok(())
320}
321
322impl SagaCommandEnvelope {
323 pub fn business_payload(&self) -> anyhow::Result<Option<nasaga_core::SagaPayload>> {
329 if self.payload.is_some() && self.raw_payload.is_some() {
330 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
331 }
332 let payload = self
333 .raw_payload
334 .clone()
335 .or_else(|| self.payload.clone().map(nasaga_core::SagaPayload::json));
336 if let Some(payload) = &payload {
337 payload
338 .validate()
339 .map_err(|_| crate::SagaCommandProcessingError::ContractInvalid)?;
340 }
341 Ok(payload)
342 }
343
344 pub fn verify_payload_contract(
352 &self,
353 content_type: &str,
354 schema_id: &str,
355 ) -> anyhow::Result<()> {
356 if let Some(payload) = self.business_payload()? {
357 if payload.content_type != content_type || payload.schema_id != schema_id {
358 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
359 }
360 }
361 Ok(())
362 }
363
364 pub fn decode_payload<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<T> {
370 let result = match self.business_payload()? {
371 None => serde_json::from_value(serde_json::Value::Null),
372 Some(payload) if payload.content_type == "application/json" => {
373 serde_json::from_slice(&payload.body)
374 }
375 Some(payload) => serde_json::from_value(serde_json::json!(payload.body)),
376 };
377 result.map_err(|_| crate::SagaCommandProcessingError::ContractInvalid.into())
378 }
379
380 pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
386 let identity = verify_identity(
387 &self.saga_id,
388 &self.tenant_id,
389 &self.workflow,
390 self.definition_version,
391 &self.definition_digest,
392 &self.step,
393 &self.phase,
394 self.attempt,
395 &self.effect_id,
396 &self.command_id,
397 )?;
398 if let Some(operation_id) = self.recovery_operation_id.as_deref() {
399 if operation_id.is_empty()
400 || operation_id.trim() != operation_id
401 || operation_id.len() > 190
402 || operation_id.chars().any(char::is_control)
403 || !matches!(identity.phase, StepPhase::Compensate | StepPhase::Resolve)
404 {
405 anyhow::bail!("recovery operation identity is invalid for the command phase");
406 }
407 }
408 Ok(identity)
409 }
410
411 pub fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
418 self.verified()
419 .map_err(|_| crate::SagaCommandProcessingError::IdentityInvalid.into())
420 }
421
422 pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
431 self.verified()?;
434 Ok(OutboxEvent {
435 event_id: self.command_id.clone(),
436 aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
437 aggregate_id: self.saga_id.clone(),
438 event_type: COMMAND_EVENT_TYPE.to_string(),
439 payload: serde_json::to_vec(self)?,
440 traceparent: None,
441 tenant: self.tenant_id.clone(),
444 })
445 }
446}
447
448impl SagaResultEnvelope {
449 pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
455 let identity = verify_identity(
456 &self.saga_id,
457 &self.tenant_id,
458 &self.workflow,
459 self.definition_version,
460 &self.definition_digest,
461 &self.step,
462 &self.phase,
463 self.attempt,
464 &self.effect_id,
465 &self.command_id,
466 )?;
467 verify_result_contract(self, &identity)?;
468 Ok(identity)
469 }
470
471 pub(crate) fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
478 let identity = verify_identity(
479 &self.saga_id,
480 &self.tenant_id,
481 &self.workflow,
482 self.definition_version,
483 &self.definition_digest,
484 &self.step,
485 &self.phase,
486 self.attempt,
487 &self.effect_id,
488 &self.command_id,
489 )
490 .map_err(|_| crate::SagaResultProcessingError::IdentityInvalid)?;
491 verify_result_contract(self, &identity)
492 .map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
493 Ok(identity)
494 }
495
496 pub fn parsed_status(&self) -> anyhow::Result<StepAttemptStatus> {
502 let status = StepAttemptStatus::parse(&self.status)
503 .ok_or_else(|| anyhow::anyhow!("unknown result status"))?;
504 if status.is_in_flight() {
505 anyhow::bail!("result status must be settled");
506 }
507 Ok(status)
508 }
509
510 pub fn parsed_terminal(&self) -> anyhow::Result<Option<StepForwardStatus>> {
516 self.terminal_status
517 .as_deref()
518 .map(|raw| {
519 StepForwardStatus::parse(raw)
520 .ok_or_else(|| anyhow::anyhow!("unknown terminal status"))
521 })
522 .transpose()
523 }
524
525 pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
531 self.verified()?;
534 Ok(OutboxEvent {
535 event_id: self.event_id.clone(),
536 aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
537 aggregate_id: self.saga_id.clone(),
538 event_type: RESULT_EVENT_TYPE.to_string(),
539 payload: serde_json::to_vec(self)?,
540 traceparent: None,
541 tenant: self.tenant_id.clone(),
542 })
543 }
544}