use naoutbox_core::OutboxEvent;
use nasaga_core::{
AttemptNo, CommandId, DefinitionVersion, EffectId, SagaId, StepAttemptStatus,
StepForwardStatus, StepName, StepPhase, TenantId, WorkflowName,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub const COMMAND_EVENT_TYPE: &str = "saga.command";
pub const RESULT_EVENT_TYPE: &str = "saga.result";
pub const SAGA_AGGREGATE_TYPE: &str = "saga";
const REASON_CODE_MAX_LEN: usize = 64;
pub(crate) fn valid_reason_code(code: &str) -> bool {
!code.is_empty()
&& code.len() <= REASON_CODE_MAX_LEN
&& code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}
const RESULT_EVENT_NAMESPACE: Uuid = Uuid::from_bytes(*b"nasasaga-v1-rsns");
#[doc(hidden)]
pub fn canonical_bytes(fields: &[&[u8]]) -> Vec<u8> {
let mut encoded = Vec::new();
for field in fields {
encoded.extend_from_slice(&(field.len() as u64).to_be_bytes());
encoded.extend_from_slice(field);
}
encoded
}
pub fn derive_result_event_id(command_id: &str) -> String {
Uuid::new_v5(
&RESULT_EVENT_NAMESPACE,
&canonical_bytes(&[command_id.as_bytes(), b"result"]),
)
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaCommandEnvelope {
pub saga_id: String,
pub tenant_id: String,
pub workflow: String,
pub definition_version: u32,
pub definition_digest: String,
pub step: String,
pub phase: String,
pub attempt: u32,
pub effect_id: String,
pub command_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recovery_operation_id: Option<String>,
pub payload: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_payload: Option<nasaga_core::SagaPayload>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaResultEnvelope {
pub event_id: String,
pub saga_id: String,
pub tenant_id: String,
pub workflow: String,
pub definition_version: u32,
pub definition_digest: String,
pub step: String,
pub phase: String,
pub attempt: u32,
pub effect_id: String,
pub command_id: String,
pub status: String,
pub terminal_status: Option<String>,
pub reason_code: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VerifiedIdentity {
pub saga_id: SagaId,
pub tenant: TenantId,
pub workflow: WorkflowName,
pub definition_version: DefinitionVersion,
pub definition_digest: String,
pub step: StepName,
pub phase: StepPhase,
pub attempt: AttemptNo,
pub effect: EffectId,
pub command: CommandId,
}
#[allow(clippy::too_many_arguments)]
fn verify_identity(
saga_id: &str,
tenant_id: &str,
workflow: &str,
definition_version: u32,
definition_digest: &str,
step: &str,
phase: &str,
attempt: u32,
effect_id: &str,
command_id: &str,
) -> anyhow::Result<VerifiedIdentity> {
let saga_id = SagaId::new(saga_id).map_err(|v| anyhow::anyhow!("bad saga id: {}", v.code()))?;
let tenant =
TenantId::new(tenant_id).map_err(|v| anyhow::anyhow!("bad tenant: {}", v.code()))?;
let workflow =
WorkflowName::new(workflow).map_err(|v| anyhow::anyhow!("bad workflow: {}", v.code()))?;
let version = DefinitionVersion::new(definition_version)
.map_err(|v| anyhow::anyhow!("bad version: {}", v.code()))?;
let step = StepName::new(step).map_err(|v| anyhow::anyhow!("bad step: {}", v.code()))?;
let phase = StepPhase::parse(phase).ok_or_else(|| anyhow::anyhow!("bad phase"))?;
let attempt =
AttemptNo::new(attempt).map_err(|v| anyhow::anyhow!("bad attempt: {}", v.code()))?;
if definition_digest.len() != 64
|| !definition_digest
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
anyhow::bail!("bad definition digest: expected 64 lowercase hex characters");
}
let effect = EffectId::derive(&saga_id, version, &step, phase);
let command = CommandId::derive(&effect, attempt);
if effect.to_string() != effect_id || command.to_string() != command_id {
anyhow::bail!("envelope identity mismatch: derived ids differ from self-reported ids");
}
Ok(VerifiedIdentity {
saga_id,
tenant,
workflow,
definition_version: version,
definition_digest: definition_digest.to_string(),
step,
phase,
attempt,
effect,
command,
})
}
fn verify_result_contract(
envelope: &SagaResultEnvelope,
identity: &VerifiedIdentity,
) -> anyhow::Result<()> {
if envelope.event_id != derive_result_event_id(&envelope.command_id) {
anyhow::bail!("result event identity does not match command identity");
}
let status = envelope.parsed_status()?;
let phase_allows_status = match identity.phase {
StepPhase::Execute => matches!(
status,
StepAttemptStatus::Succeeded
| StepAttemptStatus::Rejected
| StepAttemptStatus::Unknown
| StepAttemptStatus::Halted
),
StepPhase::Cancel => matches!(
status,
StepAttemptStatus::CancelConfirmed
| StepAttemptStatus::AlreadyTerminal
| StepAttemptStatus::ResolutionPending
),
StepPhase::Compensate => matches!(
status,
StepAttemptStatus::Succeeded | StepAttemptStatus::Unknown | StepAttemptStatus::Halted
),
StepPhase::Resolve => matches!(
status,
StepAttemptStatus::Succeeded
| StepAttemptStatus::Rejected
| StepAttemptStatus::Unknown
| StepAttemptStatus::Halted
),
};
if !phase_allows_status {
anyhow::bail!("result status is not valid for its phase");
}
match (status, envelope.parsed_terminal()?) {
(
StepAttemptStatus::AlreadyTerminal,
Some(
StepForwardStatus::Succeeded
| StepForwardStatus::Rejected
| StepForwardStatus::Halted,
),
) => {}
(StepAttemptStatus::AlreadyTerminal, _) => {
anyhow::bail!("ALREADY_TERMINAL requires a settled terminal forward status")
}
(_, Some(_)) => anyhow::bail!("terminal_status is only valid for ALREADY_TERMINAL"),
(_, None) => {}
}
if let Some(code) = envelope.reason_code.as_deref() {
if !valid_reason_code(code) {
anyhow::bail!("reason_code is not a stable bounded identifier");
}
}
Ok(())
}
impl SagaCommandEnvelope {
pub fn business_payload(&self) -> anyhow::Result<Option<nasaga_core::SagaPayload>> {
if self.payload.is_some() && self.raw_payload.is_some() {
return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
}
let payload = self
.raw_payload
.clone()
.or_else(|| self.payload.clone().map(nasaga_core::SagaPayload::json));
if let Some(payload) = &payload {
payload
.validate()
.map_err(|_| crate::SagaCommandProcessingError::ContractInvalid)?;
}
Ok(payload)
}
pub fn verify_payload_contract(
&self,
content_type: &str,
schema_id: &str,
) -> anyhow::Result<()> {
if let Some(payload) = self.business_payload()? {
if payload.content_type != content_type || payload.schema_id != schema_id {
return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
}
}
Ok(())
}
pub fn decode_payload<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<T> {
let result = match self.business_payload()? {
None => serde_json::from_value(serde_json::Value::Null),
Some(payload) if payload.content_type == "application/json" => {
serde_json::from_slice(&payload.body)
}
Some(payload) => serde_json::from_value(serde_json::json!(payload.body)),
};
result.map_err(|_| crate::SagaCommandProcessingError::ContractInvalid.into())
}
pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
let identity = verify_identity(
&self.saga_id,
&self.tenant_id,
&self.workflow,
self.definition_version,
&self.definition_digest,
&self.step,
&self.phase,
self.attempt,
&self.effect_id,
&self.command_id,
)?;
if let Some(operation_id) = self.recovery_operation_id.as_deref() {
if operation_id.is_empty()
|| operation_id.trim() != operation_id
|| operation_id.len() > 190
|| operation_id.chars().any(char::is_control)
|| !matches!(identity.phase, StepPhase::Compensate | StepPhase::Resolve)
{
anyhow::bail!("recovery operation identity is invalid for the command phase");
}
}
Ok(identity)
}
pub fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
self.verified()
.map_err(|_| crate::SagaCommandProcessingError::IdentityInvalid.into())
}
pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
self.verified()?;
Ok(OutboxEvent {
event_id: self.command_id.clone(),
aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
aggregate_id: self.saga_id.clone(),
event_type: COMMAND_EVENT_TYPE.to_string(),
payload: serde_json::to_vec(self)?,
traceparent: None,
tenant: self.tenant_id.clone(),
})
}
}
impl SagaResultEnvelope {
pub fn verified(&self) -> anyhow::Result<VerifiedIdentity> {
let identity = verify_identity(
&self.saga_id,
&self.tenant_id,
&self.workflow,
self.definition_version,
&self.definition_digest,
&self.step,
&self.phase,
self.attempt,
&self.effect_id,
&self.command_id,
)?;
verify_result_contract(self, &identity)?;
Ok(identity)
}
pub(crate) fn verified_for_delivery(&self) -> anyhow::Result<VerifiedIdentity> {
let identity = verify_identity(
&self.saga_id,
&self.tenant_id,
&self.workflow,
self.definition_version,
&self.definition_digest,
&self.step,
&self.phase,
self.attempt,
&self.effect_id,
&self.command_id,
)
.map_err(|_| crate::SagaResultProcessingError::IdentityInvalid)?;
verify_result_contract(self, &identity)
.map_err(|_| crate::SagaResultProcessingError::ContractInvalid)?;
Ok(identity)
}
pub fn parsed_status(&self) -> anyhow::Result<StepAttemptStatus> {
let status = StepAttemptStatus::parse(&self.status)
.ok_or_else(|| anyhow::anyhow!("unknown result status"))?;
if status.is_in_flight() {
anyhow::bail!("result status must be settled");
}
Ok(status)
}
pub fn parsed_terminal(&self) -> anyhow::Result<Option<StepForwardStatus>> {
self.terminal_status
.as_deref()
.map(|raw| {
StepForwardStatus::parse(raw)
.ok_or_else(|| anyhow::anyhow!("unknown terminal status"))
})
.transpose()
}
pub fn to_outbox_event(&self) -> anyhow::Result<OutboxEvent> {
self.verified()?;
Ok(OutboxEvent {
event_id: self.event_id.clone(),
aggregate_type: SAGA_AGGREGATE_TYPE.to_string(),
aggregate_id: self.saga_id.clone(),
event_type: RESULT_EVENT_TYPE.to_string(),
payload: serde_json::to_vec(self)?,
traceparent: None,
tenant: self.tenant_id.clone(),
})
}
}