1use nainbox_core::{InboxClaim, InboxStore};
13use naoutbox_core::DurableOutboxAppend;
14use natelemetry::TraceContext;
15use std::collections::{BTreeMap, BTreeSet};
16
17use nasaga_backend::{
18 CancelAdjudication, CompensationAdmission, ExecuteAdmission, ExternalCancelAdmission,
19 ParticipantGateKey, ResolutionAdmission, SagaBackend, SagaBackendFactory, SagaParticipantStore,
20};
21use nasaga_core::{
22 CancelOutcome, CompensationOutcome, DefinitionVersion, EffectId, SagaCancelStep, SagaContext,
23 SagaExecutionError, SagaOutcome, SagaResolveStep, SagaStep, ServiceIdentity, StepAttemptStatus,
24 StepCancelStatus, StepCompensationStatus, StepForwardStatus, StepPhase, StepResolutionStatus,
25 TerminalForwardOutcome, WorkflowName,
26};
27use serde::de::DeserializeOwned;
28
29use crate::envelope::{
30 derive_result_event_id, valid_reason_code, SagaCommandEnvelope, SagaResultEnvelope,
31};
32
33pub trait SagaCommandService<B: SagaBackend>: Send + Sync + 'static {
38 fn handle_saga_command<'a>(
47 &'a self,
48 runtime: &'a ParticipantRuntime<B>,
49 envelope: &'a SagaCommandEnvelope,
50 producer: &'a ServiceIdentity,
51 ) -> impl std::future::Future<Output = anyhow::Result<ParticipantHandled>> + Send + 'a;
52
53 fn handle_saga_command_traced<'a>(
68 &'a self,
69 runtime: &'a ParticipantRuntime<B>,
70 envelope: &'a SagaCommandEnvelope,
71 producer: &'a ServiceIdentity,
72 receipt_trace: Option<&'a TraceContext>,
73 ) -> impl std::future::Future<Output = anyhow::Result<ParticipantHandled>> + Send + 'a {
74 let _ = receipt_trace;
75 self.handle_saga_command(runtime, envelope, producer)
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ParticipantHandled {
85 Executed(StepAttemptStatus),
87 Duplicate,
89 Suppressed,
91 Replayed(StepAttemptStatus),
93 ContractViolation,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ParticipantCommandTrust {
103 producer: ServiceIdentity,
104 workflow: WorkflowName,
105 definition_version: DefinitionVersion,
106 definition_digest: Option<String>,
107}
108
109impl ParticipantCommandTrust {
110 pub fn new(
120 producer: ServiceIdentity,
121 workflow: WorkflowName,
122 definition_version: DefinitionVersion,
123 definition_digest: impl Into<String>,
124 ) -> anyhow::Result<Self> {
125 let definition_digest = definition_digest.into();
126 if !valid_definition_digest(&definition_digest) {
127 anyhow::bail!("participant command trust requires a canonical definition digest");
128 }
129 Ok(Self {
130 producer,
131 workflow,
132 definition_version,
133 definition_digest: Some(definition_digest),
134 })
135 }
136
137 pub fn for_definition_version(
146 producer: ServiceIdentity,
147 workflow: WorkflowName,
148 definition_version: DefinitionVersion,
149 ) -> Self {
150 Self {
151 producer,
152 workflow,
153 definition_version,
154 definition_digest: None,
155 }
156 }
157}
158
159pub struct ParticipantRuntime<B: SagaBackend> {
163 backend: B,
164 consumer: String,
165 trusted_command_producers: BTreeSet<ServiceIdentity>,
166 command_trust: BTreeMap<(WorkflowName, DefinitionVersion, Option<String>), ServiceIdentity>,
167}
168
169pub struct AuthenticatedParticipantRuntime<'a, B: SagaBackend> {
174 runtime: &'a ParticipantRuntime<B>,
175 producer: &'a ServiceIdentity,
176}
177
178impl<B> ParticipantRuntime<B>
179where
180 B: SagaBackend + SagaBackendFactory,
181{
182 pub fn new(
191 consumer: impl Into<String>,
192 command_trust: impl IntoIterator<Item = ParticipantCommandTrust>,
193 ) -> anyhow::Result<Self> {
194 Self::build(consumer, command_trust, B::default_backend()?)
195 }
196
197 pub fn with_datasource(
206 consumer: impl Into<String>,
207 command_trust: impl IntoIterator<Item = ParticipantCommandTrust>,
208 datasource: impl AsRef<str>,
209 ) -> anyhow::Result<Self> {
210 Self::build(
211 consumer,
212 command_trust,
213 B::backend_for(datasource.as_ref())?,
214 )
215 }
216
217 pub fn build(
223 consumer: impl Into<String>,
224 command_trust: impl IntoIterator<Item = ParticipantCommandTrust>,
225 backend: B,
226 ) -> anyhow::Result<Self> {
227 let consumer = consumer.into();
228 if consumer.is_empty()
229 || consumer.trim() != consumer
230 || consumer.len() > 128
231 || consumer.contains('\0')
232 || !consumer.bytes().all(|byte| {
233 byte.is_ascii_lowercase()
234 || byte.is_ascii_digit()
235 || matches!(byte, b'_' | b'-' | b'.')
236 })
237 {
238 anyhow::bail!("participant consumer must satisfy the Inbox key contract");
239 }
240 let mut trusted_command_producers = BTreeSet::new();
241 let mut projected_trust = BTreeMap::new();
242 for trust in command_trust {
243 trusted_command_producers.insert(trust.producer.clone());
244 let key = (
245 trust.workflow,
246 trust.definition_version,
247 trust.definition_digest,
248 );
249 if projected_trust.insert(key, trust.producer).is_some() {
250 anyhow::bail!(
251 "participant command trust contains a duplicate definition projection"
252 );
253 }
254 }
255 if projected_trust.is_empty() {
256 anyhow::bail!("participant requires at least one command trust projection");
257 }
258 Ok(Self {
259 backend,
260 consumer,
261 trusted_command_producers,
262 command_trust: projected_trust,
263 })
264 }
265
266 pub fn datasource_ref(&self) -> &natx_core::DatasourceRef {
272 self.backend.datasource_ref()
273 }
274
275 pub fn authenticate<'a>(
283 &'a self,
284 producer: &'a ServiceIdentity,
285 ) -> anyhow::Result<AuthenticatedParticipantRuntime<'a, B>> {
286 if !self.trusted_command_producers.contains(producer) {
287 return Err(crate::SagaCommandProcessingError::ProducerUnauthorized.into());
288 }
289 Ok(AuthenticatedParticipantRuntime {
290 runtime: self,
291 producer,
292 })
293 }
294
295 pub fn single_trusted_command_producer(&self) -> anyhow::Result<ServiceIdentity> {
304 if self.trusted_command_producers.len() != 1 {
305 anyhow::bail!(
306 "participant transport requires an explicit producer when trust contains multiple producers"
307 );
308 }
309 self.trusted_command_producers
310 .iter()
311 .next()
312 .cloned()
313 .ok_or_else(|| anyhow::anyhow!("participant command trust must not be empty"))
314 }
315
316 pub fn verify_authenticated_command(
327 &self,
328 envelope: &SagaCommandEnvelope,
329 producer: &ServiceIdentity,
330 ) -> anyhow::Result<()> {
331 self.verified_command_identity(envelope, producer).map(drop)
332 }
333
334 pub async fn handle_authenticated_execute<S>(
346 &self,
347 service: &S,
348 envelope: &SagaCommandEnvelope,
349 producer: &ServiceIdentity,
350 allow_unknown: bool,
351 ) -> anyhow::Result<ParticipantHandled>
352 where
353 S: SagaStep,
354 S::Command: DeserializeOwned,
355 {
356 self.handle_authenticated_execute_traced(service, envelope, producer, None, allow_unknown)
357 .await
358 }
359
360 pub async fn handle_authenticated_execute_traced<S>(
373 &self,
374 service: &S,
375 envelope: &SagaCommandEnvelope,
376 producer: &ServiceIdentity,
377 receipt_trace: Option<&TraceContext>,
378 allow_unknown: bool,
379 ) -> anyhow::Result<ParticipantHandled>
380 where
381 S: SagaStep,
382 S::Command: DeserializeOwned,
383 {
384 let identity = self.verified_command_identity(envelope, producer)?;
385 if identity.phase != StepPhase::Execute {
386 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
387 }
388 let _latency =
389 crate::latency::LatencyGuard::new(crate::latency::LatencyStage::ParticipantTransaction);
390 crate::transaction::run_for(&self.backend, async {
391 if matches!(
392 self.backend
393 .inbox()
394 .claim(&self.consumer, &envelope.command_id)
395 .await?,
396 InboxClaim::Duplicate
397 ) {
398 return Ok(ParticipantHandled::Duplicate);
399 }
400 let gate = ParticipantGateKey {
401 saga_id: &identity.saga_id,
402 step: &identity.step,
403 tenant: &identity.tenant,
404 workflow: &identity.workflow,
405 definition_version: identity.definition_version,
406 definition_digest: &envelope.definition_digest,
407 };
408 match self
409 .backend
410 .store()
411 .admit_execute(&gate, &identity.effect)
412 .await?
413 {
414 ExecuteAdmission::Suppressed => Ok(ParticipantHandled::Suppressed),
417 ExecuteAdmission::AlreadyTerminal(terminal) => {
420 let status = forward_to_attempt_status(terminal)?;
421 self.emit_result(receipt_trace, envelope, status, None, None)
422 .await?;
423 Ok(ParticipantHandled::Replayed(status))
424 }
425 ExecuteAdmission::Admitted => {
426 let context = self.context_of(&identity, envelope)?;
427 let command: S::Command = envelope.decode_payload()?;
428 match crate::latency::measure_handler(service.execute(&context, command)).await
429 {
430 Ok(SagaOutcome::Succeeded(_)) => {
431 self.backend
432 .store()
433 .settle_execute(
434 &identity.saga_id,
435 &identity.step,
436 StepForwardStatus::Succeeded,
437 )
438 .await?;
439 self.emit_result(
440 receipt_trace,
441 envelope,
442 StepAttemptStatus::Succeeded,
443 None,
444 None,
445 )
446 .await?;
447 Ok(ParticipantHandled::Executed(StepAttemptStatus::Succeeded))
448 }
449 Ok(SagaOutcome::Rejected { code }) => {
450 self.backend
452 .store()
453 .settle_execute(
454 &identity.saga_id,
455 &identity.step,
456 StepForwardStatus::Rejected,
457 )
458 .await?;
459 self.emit_result(
460 receipt_trace,
461 envelope,
462 StepAttemptStatus::Rejected,
463 None,
464 Some(safe_reason_code(code)),
465 )
466 .await?;
467 Ok(ParticipantHandled::Executed(StepAttemptStatus::Rejected))
468 }
469 Ok(SagaOutcome::Unknown { code }) => {
470 if allow_unknown {
471 self.backend
472 .store()
473 .settle_execute(
474 &identity.saga_id,
475 &identity.step,
476 StepForwardStatus::Unknown,
477 )
478 .await?;
479 self.emit_result(
480 receipt_trace,
481 envelope,
482 StepAttemptStatus::Unknown,
483 None,
484 Some(safe_reason_code(code)),
485 )
486 .await?;
487 Ok(ParticipantHandled::Executed(StepAttemptStatus::Unknown))
488 } else {
489 self.backend
492 .store()
493 .settle_execute(
494 &identity.saga_id,
495 &identity.step,
496 StepForwardStatus::Halted,
497 )
498 .await?;
499 self.emit_result(
500 receipt_trace,
501 envelope,
502 StepAttemptStatus::Halted,
503 None,
504 Some("unknown_not_allowed"),
505 )
506 .await?;
507 Ok(ParticipantHandled::Executed(StepAttemptStatus::Halted))
508 }
509 }
510 Ok(SagaOutcome::Halted { code }) => {
511 self.backend
512 .store()
513 .settle_execute(
514 &identity.saga_id,
515 &identity.step,
516 StepForwardStatus::Halted,
517 )
518 .await?;
519 self.emit_result(
520 receipt_trace,
521 envelope,
522 StepAttemptStatus::Halted,
523 None,
524 Some(safe_reason_code(code)),
525 )
526 .await?;
527 Ok(ParticipantHandled::Executed(StepAttemptStatus::Halted))
528 }
529 Err(SagaExecutionError::Retryable { code }) => {
531 anyhow::bail!("retryable execution error: {code}")
532 }
533 }
534 }
535 }
536 })
537 .await
538 }
539
540 pub async fn handle_authenticated_cancel_local(
549 &self,
550 envelope: &SagaCommandEnvelope,
551 producer: &ServiceIdentity,
552 ) -> anyhow::Result<ParticipantHandled> {
553 self.handle_authenticated_cancel_local_traced(envelope, producer, None)
554 .await
555 }
556
557 pub async fn handle_authenticated_cancel_local_traced(
567 &self,
568 envelope: &SagaCommandEnvelope,
569 producer: &ServiceIdentity,
570 receipt_trace: Option<&TraceContext>,
571 ) -> anyhow::Result<ParticipantHandled> {
572 let identity = self.verified_command_identity(envelope, producer)?;
573 if identity.phase != StepPhase::Cancel {
574 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
575 }
576 let _latency =
577 crate::latency::LatencyGuard::new(crate::latency::LatencyStage::ParticipantTransaction);
578 crate::transaction::run_for(&self.backend, async {
579 if matches!(
580 self.backend
581 .inbox()
582 .claim(&self.consumer, &envelope.command_id)
583 .await?,
584 InboxClaim::Duplicate
585 ) {
586 return Ok(ParticipantHandled::Duplicate);
587 }
588 let gate = ParticipantGateKey {
589 saga_id: &identity.saga_id,
590 step: &identity.step,
591 tenant: &identity.tenant,
592 workflow: &identity.workflow,
593 definition_version: identity.definition_version,
594 definition_digest: &envelope.definition_digest,
595 };
596 let execute_effect = EffectId::derive(
599 &identity.saga_id,
600 identity.definition_version,
601 &identity.step,
602 StepPhase::Execute,
603 );
604 let (status, terminal) = match self
605 .backend
606 .store()
607 .adjudicate_cancel(&gate, &execute_effect, &identity.effect)
608 .await?
609 {
610 CancelAdjudication::Confirmed => (StepAttemptStatus::CancelConfirmed, None),
611 CancelAdjudication::AlreadyTerminal(forward) => {
612 (StepAttemptStatus::AlreadyTerminal, Some(forward))
613 }
614 CancelAdjudication::ResolutionPending => {
615 (StepAttemptStatus::ResolutionPending, None)
616 }
617 };
618 self.emit_result(receipt_trace, envelope, status, terminal, None)
619 .await?;
620 Ok(ParticipantHandled::Executed(status))
621 })
622 .await
623 }
624
625 pub async fn handle_authenticated_cancel_external<C>(
639 &self,
640 service: &C,
641 envelope: &SagaCommandEnvelope,
642 producer: &ServiceIdentity,
643 ) -> anyhow::Result<ParticipantHandled>
644 where
645 C: SagaCancelStep,
646 C::Command: DeserializeOwned,
647 {
648 self.handle_authenticated_cancel_external_traced(service, envelope, producer, None)
649 .await
650 }
651
652 pub async fn handle_authenticated_cancel_external_traced<C>(
663 &self,
664 service: &C,
665 envelope: &SagaCommandEnvelope,
666 producer: &ServiceIdentity,
667 receipt_trace: Option<&TraceContext>,
668 ) -> anyhow::Result<ParticipantHandled>
669 where
670 C: SagaCancelStep,
671 C::Command: DeserializeOwned,
672 {
673 let identity = self.verified_command_identity(envelope, producer)?;
674 if identity.phase != StepPhase::Cancel {
675 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
676 }
677 let _latency =
678 crate::latency::LatencyGuard::new(crate::latency::LatencyStage::ParticipantTransaction);
679 crate::transaction::run_for(&self.backend, async {
680 if matches!(
681 self.backend
682 .inbox()
683 .claim(&self.consumer, &envelope.command_id)
684 .await?,
685 InboxClaim::Duplicate
686 ) {
687 return Ok(ParticipantHandled::Duplicate);
688 }
689 let gate = ParticipantGateKey {
690 saga_id: &identity.saga_id,
691 step: &identity.step,
692 tenant: &identity.tenant,
693 workflow: &identity.workflow,
694 definition_version: identity.definition_version,
695 definition_digest: &envelope.definition_digest,
696 };
697 let execute_effect = EffectId::derive(
698 &identity.saga_id,
699 identity.definition_version,
700 &identity.step,
701 StepPhase::Execute,
702 );
703 match self
704 .backend
705 .store()
706 .admit_external_cancel(&gate, &execute_effect, &identity.effect)
707 .await?
708 {
709 ExternalCancelAdmission::AlreadyAdjudicated(adjudication) => {
710 let (status, terminal) = cancel_adjudication_result(adjudication);
713 self.emit_result(receipt_trace, envelope, status, terminal, None)
714 .await?;
715 Ok(ParticipantHandled::Replayed(status))
716 }
717 ExternalCancelAdmission::Admitted => {
718 let context = self
721 .context_of(&identity, envelope)?
722 .with_target_phase(StepPhase::Execute);
723 let command: C::Command = envelope.decode_payload()?;
724 match crate::latency::measure_handler(service.cancel(&context, command)).await {
725 Ok(outcome) => {
726 let (forward, cancel, status, terminal, reason) =
727 external_cancel_result(outcome);
728 let reason = reason.map(safe_reason_code);
729 self.backend
732 .store()
733 .settle_external_cancel(
734 &identity.saga_id,
735 &identity.step,
736 forward,
737 cancel,
738 )
739 .await?;
740 self.emit_result(receipt_trace, envelope, status, terminal, reason)
741 .await?;
742 Ok(ParticipantHandled::Executed(status))
743 }
744 Err(SagaExecutionError::Retryable { code }) => {
745 anyhow::bail!("retryable external cancel error: {code}")
748 }
749 }
750 }
751 }
752 })
753 .await
754 }
755
756 pub async fn handle_authenticated_compensate<S>(
767 &self,
768 service: &S,
769 envelope: &SagaCommandEnvelope,
770 producer: &ServiceIdentity,
771 ) -> anyhow::Result<ParticipantHandled>
772 where
773 S: SagaStep,
774 S::Compensation: DeserializeOwned,
775 {
776 self.handle_authenticated_compensate_traced(service, envelope, producer, None)
777 .await
778 }
779
780 pub async fn handle_authenticated_compensate_traced<S>(
791 &self,
792 service: &S,
793 envelope: &SagaCommandEnvelope,
794 producer: &ServiceIdentity,
795 receipt_trace: Option<&TraceContext>,
796 ) -> anyhow::Result<ParticipantHandled>
797 where
798 S: SagaStep,
799 S::Compensation: DeserializeOwned,
800 {
801 let identity = self.verified_command_identity(envelope, producer)?;
802 if identity.phase != StepPhase::Compensate {
803 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
804 }
805 let _latency =
806 crate::latency::LatencyGuard::new(crate::latency::LatencyStage::ParticipantTransaction);
807 crate::transaction::run_for(&self.backend, async {
808 if matches!(
809 self.backend
810 .inbox()
811 .claim(&self.consumer, &envelope.command_id)
812 .await?,
813 InboxClaim::Duplicate
814 ) {
815 return Ok(ParticipantHandled::Duplicate);
816 }
817 let gate = ParticipantGateKey {
818 saga_id: &identity.saga_id,
819 step: &identity.step,
820 tenant: &identity.tenant,
821 workflow: &identity.workflow,
822 definition_version: identity.definition_version,
823 definition_digest: &envelope.definition_digest,
824 };
825 match self
826 .backend
827 .store()
828 .admit_compensation(
829 &gate,
830 &identity.effect,
831 envelope.recovery_operation_id.as_deref(),
832 )
833 .await?
834 {
835 CompensationAdmission::AlreadySettled(terminal) => {
838 let status = compensation_to_attempt_status(terminal)?;
839 let reason = match terminal {
840 StepCompensationStatus::Unknown => Some("compensation_outcome_unknown"),
841 StepCompensationStatus::Halted => Some("compensation_already_halted"),
842 _ => None,
843 };
844 self.emit_result(receipt_trace, envelope, status, None, reason)
845 .await?;
846 Ok(ParticipantHandled::Replayed(status))
847 }
848 CompensationAdmission::MissingForwardEffect => {
851 self.emit_result(
852 receipt_trace,
853 envelope,
854 StepAttemptStatus::Halted,
855 None,
856 Some("compensation_contract_violation"),
857 )
858 .await?;
859 Ok(ParticipantHandled::ContractViolation)
860 }
861 CompensationAdmission::Admitted => {
862 let context = self.context_of(&identity, envelope)?;
863 let command: S::Compensation = envelope.decode_payload()?;
866 match crate::latency::measure_handler(service.compensate(&context, command))
867 .await
868 {
869 Ok(CompensationOutcome::Succeeded) => {
870 self.backend
871 .store()
872 .settle_compensation(
873 &identity.saga_id,
874 &identity.step,
875 StepCompensationStatus::Succeeded,
876 )
877 .await?;
878 self.emit_result(
879 receipt_trace,
880 envelope,
881 StepAttemptStatus::Succeeded,
882 None,
883 None,
884 )
885 .await?;
886 Ok(ParticipantHandled::Executed(StepAttemptStatus::Succeeded))
887 }
888 Ok(CompensationOutcome::Unknown { code }) => {
889 self.backend
890 .store()
891 .settle_compensation(
892 &identity.saga_id,
893 &identity.step,
894 StepCompensationStatus::Unknown,
895 )
896 .await?;
897 self.emit_result(
898 receipt_trace,
899 envelope,
900 StepAttemptStatus::Unknown,
901 None,
902 Some(safe_reason_code(code)),
903 )
904 .await?;
905 Ok(ParticipantHandled::Executed(StepAttemptStatus::Unknown))
906 }
907 Ok(CompensationOutcome::Halted { code }) => {
908 self.backend
909 .store()
910 .settle_compensation(
911 &identity.saga_id,
912 &identity.step,
913 StepCompensationStatus::Halted,
914 )
915 .await?;
916 self.emit_result(
917 receipt_trace,
918 envelope,
919 StepAttemptStatus::Halted,
920 None,
921 Some(safe_reason_code(code)),
922 )
923 .await?;
924 Ok(ParticipantHandled::Executed(StepAttemptStatus::Halted))
925 }
926 Err(SagaExecutionError::Retryable { code }) => {
927 anyhow::bail!("retryable compensation error: {code}")
928 }
929 }
930 }
931 }
932 })
933 .await
934 }
935
936 pub async fn handle_authenticated_resolve<R>(
950 &self,
951 service: &R,
952 envelope: &SagaCommandEnvelope,
953 producer: &ServiceIdentity,
954 ) -> anyhow::Result<ParticipantHandled>
955 where
956 R: SagaResolveStep,
957 R::Command: DeserializeOwned,
958 {
959 self.handle_authenticated_resolve_traced(service, envelope, producer, None)
960 .await
961 }
962
963 pub async fn handle_authenticated_resolve_traced<R>(
974 &self,
975 service: &R,
976 envelope: &SagaCommandEnvelope,
977 producer: &ServiceIdentity,
978 receipt_trace: Option<&TraceContext>,
979 ) -> anyhow::Result<ParticipantHandled>
980 where
981 R: SagaResolveStep,
982 R::Command: DeserializeOwned,
983 {
984 let identity = self.verified_command_identity(envelope, producer)?;
985 if identity.phase != StepPhase::Resolve {
986 return Err(crate::SagaCommandProcessingError::ContractInvalid.into());
987 }
988 let _latency =
989 crate::latency::LatencyGuard::new(crate::latency::LatencyStage::ParticipantTransaction);
990 crate::transaction::run_for(&self.backend, async {
991 if matches!(
992 self.backend
993 .inbox()
994 .claim(&self.consumer, &envelope.command_id)
995 .await?,
996 InboxClaim::Duplicate
997 ) {
998 return Ok(ParticipantHandled::Duplicate);
999 }
1000 let gate = ParticipantGateKey {
1001 saga_id: &identity.saga_id,
1002 step: &identity.step,
1003 tenant: &identity.tenant,
1004 workflow: &identity.workflow,
1005 definition_version: identity.definition_version,
1006 definition_digest: &envelope.definition_digest,
1007 };
1008 match self
1009 .backend
1010 .store()
1011 .admit_resolution(
1012 &gate,
1013 &identity.effect,
1014 envelope.recovery_operation_id.as_deref(),
1015 )
1016 .await?
1017 {
1018 ResolutionAdmission::AlreadySettled { status, .. } => {
1019 let attempt_status = resolution_to_attempt_status(status)?;
1020 self.emit_result(receipt_trace, envelope, attempt_status, None, None)
1021 .await?;
1022 Ok(ParticipantHandled::Replayed(attempt_status))
1023 }
1024 ResolutionAdmission::MissingUnknownEffect => {
1025 self.emit_result(
1028 receipt_trace,
1029 envelope,
1030 StepAttemptStatus::Halted,
1031 None,
1032 Some("resolution_contract_violation"),
1033 )
1034 .await?;
1035 Ok(ParticipantHandled::ContractViolation)
1036 }
1037 ResolutionAdmission::Admitted(target) => {
1038 let target_phase = match target {
1041 nasaga_backend::ResolutionTarget::Forward => StepPhase::Execute,
1042 nasaga_backend::ResolutionTarget::Compensation => StepPhase::Compensate,
1043 };
1044 let context = self
1045 .context_of(&identity, envelope)?
1046 .with_target_phase(target_phase);
1047 let command: R::Command = envelope.decode_payload()?;
1048 match crate::latency::measure_handler(service.resolve(&context, command)).await
1049 {
1050 Ok(SagaOutcome::Succeeded(_)) => {
1051 self.backend
1052 .store()
1053 .settle_resolution(
1054 &identity.saga_id,
1055 &identity.step,
1056 target,
1057 StepResolutionStatus::Succeeded,
1058 )
1059 .await?;
1060 self.emit_result(
1061 receipt_trace,
1062 envelope,
1063 StepAttemptStatus::Succeeded,
1064 None,
1065 None,
1066 )
1067 .await?;
1068 Ok(ParticipantHandled::Executed(StepAttemptStatus::Succeeded))
1069 }
1070 Ok(SagaOutcome::Rejected { code }) => {
1071 self.backend
1074 .store()
1075 .settle_resolution(
1076 &identity.saga_id,
1077 &identity.step,
1078 target,
1079 StepResolutionStatus::Rejected,
1080 )
1081 .await?;
1082 self.emit_result(
1083 receipt_trace,
1084 envelope,
1085 StepAttemptStatus::Rejected,
1086 None,
1087 Some(safe_reason_code(code)),
1088 )
1089 .await?;
1090 Ok(ParticipantHandled::Executed(StepAttemptStatus::Rejected))
1091 }
1092 Ok(SagaOutcome::Unknown { code }) => {
1093 self.backend
1094 .store()
1095 .settle_resolution(
1096 &identity.saga_id,
1097 &identity.step,
1098 target,
1099 StepResolutionStatus::Pending,
1100 )
1101 .await?;
1102 self.emit_result(
1103 receipt_trace,
1104 envelope,
1105 StepAttemptStatus::Unknown,
1106 None,
1107 Some(safe_reason_code(code)),
1108 )
1109 .await?;
1110 Ok(ParticipantHandled::Executed(StepAttemptStatus::Unknown))
1111 }
1112 Ok(SagaOutcome::Halted { code }) => {
1113 self.backend
1114 .store()
1115 .settle_resolution(
1116 &identity.saga_id,
1117 &identity.step,
1118 target,
1119 StepResolutionStatus::Halted,
1120 )
1121 .await?;
1122 self.emit_result(
1123 receipt_trace,
1124 envelope,
1125 StepAttemptStatus::Halted,
1126 None,
1127 Some(safe_reason_code(code)),
1128 )
1129 .await?;
1130 Ok(ParticipantHandled::Executed(StepAttemptStatus::Halted))
1131 }
1132 Err(SagaExecutionError::Retryable { code }) => {
1133 anyhow::bail!("retryable resolution error: {code}")
1135 }
1136 }
1137 }
1138 }
1139 })
1140 .await
1141 }
1142
1143 fn verified_command_identity(
1154 &self,
1155 envelope: &SagaCommandEnvelope,
1156 producer: &ServiceIdentity,
1157 ) -> anyhow::Result<crate::envelope::VerifiedIdentity> {
1158 let identity = envelope
1159 .verified()
1160 .map_err(|_| crate::SagaCommandProcessingError::IdentityInvalid)?;
1161 self.verify_command_producer(producer, &identity)?;
1162 let descriptor = crate::COLLECTED_SAGA_STEPS.iter().find(|descriptor| {
1164 descriptor.workflow == envelope.workflow
1165 && descriptor.definition_version == envelope.definition_version
1166 && descriptor.step == envelope.step
1167 });
1168 let (content_type, schema_id) = descriptor.map_or(("application/json", ""), |descriptor| {
1169 (
1170 descriptor.payload_content_type,
1171 descriptor.payload_schema_id,
1172 )
1173 });
1174 envelope.verify_payload_contract(content_type, schema_id)?;
1175 Ok(identity)
1176 }
1177
1178 fn verify_command_producer(
1186 &self,
1187 producer: &ServiceIdentity,
1188 identity: &crate::envelope::VerifiedIdentity,
1189 ) -> anyhow::Result<()> {
1190 let key = (
1191 identity.workflow.clone(),
1192 identity.definition_version,
1193 Some(identity.definition_digest.clone()),
1194 );
1195 let version_key = (identity.workflow.clone(), identity.definition_version, None);
1196 if self.command_trust.get(&key) != Some(producer)
1197 && self.command_trust.get(&version_key) != Some(producer)
1198 {
1199 return Err(crate::SagaCommandProcessingError::ProducerUnauthorized.into());
1200 }
1201 Ok(())
1202 }
1203
1204 fn context_of(
1212 &self,
1213 identity: &crate::envelope::VerifiedIdentity,
1214 envelope: &SagaCommandEnvelope,
1215 ) -> anyhow::Result<SagaContext> {
1216 Ok(SagaContext::new(
1217 identity.saga_id.clone(),
1218 identity.tenant.clone(),
1219 identity.workflow.clone(),
1220 identity.definition_version,
1221 envelope.definition_digest.clone(),
1222 identity.step.clone(),
1223 identity.phase,
1224 identity.attempt,
1225 )
1226 .with_payload(envelope.business_payload()?))
1227 }
1228
1229 async fn emit_result(
1244 &self,
1245 receipt_trace: Option<&TraceContext>,
1246 envelope: &SagaCommandEnvelope,
1247 status: StepAttemptStatus,
1248 terminal: Option<StepForwardStatus>,
1249 reason_code: Option<&str>,
1250 ) -> anyhow::Result<()> {
1251 let result = SagaResultEnvelope {
1252 event_id: derive_result_event_id(&envelope.command_id),
1253 saga_id: envelope.saga_id.clone(),
1254 tenant_id: envelope.tenant_id.clone(),
1255 workflow: envelope.workflow.clone(),
1256 definition_version: envelope.definition_version,
1257 definition_digest: envelope.definition_digest.clone(),
1258 step: envelope.step.clone(),
1259 phase: envelope.phase.clone(),
1260 attempt: envelope.attempt,
1261 effect_id: envelope.effect_id.clone(),
1262 command_id: envelope.command_id.clone(),
1263 status: status.as_str().to_string(),
1264 terminal_status: terminal.map(|value| value.as_str().to_string()),
1265 reason_code: reason_code.map(str::to_string),
1266 };
1267 let event = result
1271 .to_outbox_event()
1272 .map_err(|_| crate::SagaCommandProcessingError::ContractInvalid)?;
1273 let event = match receipt_trace {
1276 Some(trace) => {
1277 event.with_traceparent(trace.child(natelemetry::random_span_id()).to_traceparent())
1278 }
1279 None => event,
1280 };
1281 let write_context = naoutbox_core::OutboxWriteContext::new(envelope.tenant_id.as_str())
1284 .map_err(|_| crate::SagaCommandProcessingError::ContractInvalid)?;
1285 self.backend
1286 .outbox()
1287 .append_transactional_with_context(&write_context, &event)
1288 .await?;
1289 Ok(())
1290 }
1291}
1292
1293impl<B> AuthenticatedParticipantRuntime<'_, B>
1294where
1295 B: SagaBackend + SagaBackendFactory,
1296{
1297 pub async fn execute<S>(
1306 &self,
1307 service: &S,
1308 envelope: &SagaCommandEnvelope,
1309 allow_unknown: bool,
1310 ) -> anyhow::Result<ParticipantHandled>
1311 where
1312 S: SagaStep,
1313 S::Command: DeserializeOwned,
1314 {
1315 self.runtime
1316 .handle_authenticated_execute(service, envelope, self.producer, allow_unknown)
1317 .await
1318 }
1319
1320 pub async fn cancel_local(
1327 &self,
1328 envelope: &SagaCommandEnvelope,
1329 ) -> anyhow::Result<ParticipantHandled> {
1330 self.runtime
1331 .handle_authenticated_cancel_local(envelope, self.producer)
1332 .await
1333 }
1334
1335 pub async fn cancel_external<C>(
1343 &self,
1344 service: &C,
1345 envelope: &SagaCommandEnvelope,
1346 ) -> anyhow::Result<ParticipantHandled>
1347 where
1348 C: SagaCancelStep,
1349 C::Command: DeserializeOwned,
1350 {
1351 self.runtime
1352 .handle_authenticated_cancel_external(service, envelope, self.producer)
1353 .await
1354 }
1355
1356 pub async fn compensate<S>(
1364 &self,
1365 service: &S,
1366 envelope: &SagaCommandEnvelope,
1367 ) -> anyhow::Result<ParticipantHandled>
1368 where
1369 S: SagaStep,
1370 S::Compensation: DeserializeOwned,
1371 {
1372 self.runtime
1373 .handle_authenticated_compensate(service, envelope, self.producer)
1374 .await
1375 }
1376
1377 pub async fn resolve<R>(
1385 &self,
1386 service: &R,
1387 envelope: &SagaCommandEnvelope,
1388 ) -> anyhow::Result<ParticipantHandled>
1389 where
1390 R: SagaResolveStep,
1391 R::Command: DeserializeOwned,
1392 {
1393 self.runtime
1394 .handle_authenticated_resolve(service, envelope, self.producer)
1395 .await
1396 }
1397}
1398
1399fn valid_definition_digest(digest: &str) -> bool {
1406 digest.len() == 64
1407 && digest
1408 .bytes()
1409 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1410}
1411
1412fn safe_reason_code(code: &'static str) -> &'static str {
1420 if valid_reason_code(code) {
1421 code
1422 } else {
1423 tracing::error!(
1424 reason_code_valid = false,
1425 "Saga 参与方原因码不符合传输合同,已安全降级并保留业务裁决"
1426 );
1427 "participant_reason_code_invalid"
1428 }
1429}
1430
1431fn forward_to_attempt_status(terminal: StepForwardStatus) -> anyhow::Result<StepAttemptStatus> {
1438 match terminal {
1439 StepForwardStatus::Succeeded => Ok(StepAttemptStatus::Succeeded),
1440 StepForwardStatus::Rejected => Ok(StepAttemptStatus::Rejected),
1441 StepForwardStatus::Unknown => Ok(StepAttemptStatus::Unknown),
1442 StepForwardStatus::Halted => Ok(StepAttemptStatus::Halted),
1443 _ => anyhow::bail!("gate terminal status is not replayable"),
1444 }
1445}
1446
1447fn cancel_adjudication_result(
1454 adjudication: CancelAdjudication,
1455) -> (StepAttemptStatus, Option<StepForwardStatus>) {
1456 match adjudication {
1457 CancelAdjudication::Confirmed => (StepAttemptStatus::CancelConfirmed, None),
1458 CancelAdjudication::AlreadyTerminal(forward) => {
1459 (StepAttemptStatus::AlreadyTerminal, Some(forward))
1460 }
1461 CancelAdjudication::ResolutionPending => (StepAttemptStatus::ResolutionPending, None),
1462 }
1463}
1464
1465#[allow(clippy::type_complexity)]
1472fn external_cancel_result(
1473 outcome: CancelOutcome,
1474) -> (
1475 StepForwardStatus,
1476 StepCancelStatus,
1477 StepAttemptStatus,
1478 Option<StepForwardStatus>,
1479 Option<&'static str>,
1480) {
1481 match outcome {
1482 CancelOutcome::CancelConfirmed => (
1483 StepForwardStatus::Cancelled,
1484 StepCancelStatus::Confirmed,
1485 StepAttemptStatus::CancelConfirmed,
1486 None,
1487 None,
1488 ),
1489 CancelOutcome::AlreadyTerminal(terminal) => {
1490 let (forward, reason) = match terminal {
1491 TerminalForwardOutcome::Succeeded => (StepForwardStatus::Succeeded, None),
1492 TerminalForwardOutcome::Rejected { code } => {
1493 (StepForwardStatus::Rejected, Some(code))
1494 }
1495 TerminalForwardOutcome::Halted { code } => (StepForwardStatus::Halted, Some(code)),
1496 };
1497 (
1498 forward,
1499 StepCancelStatus::AlreadyTerminal,
1500 StepAttemptStatus::AlreadyTerminal,
1501 Some(forward),
1502 reason,
1503 )
1504 }
1505 CancelOutcome::ResolutionPending { code } => (
1506 StepForwardStatus::Unknown,
1507 StepCancelStatus::ResolutionPending,
1508 StepAttemptStatus::ResolutionPending,
1509 None,
1510 Some(code),
1511 ),
1512 }
1513}
1514
1515fn compensation_to_attempt_status(
1523 terminal: StepCompensationStatus,
1524) -> anyhow::Result<StepAttemptStatus> {
1525 match terminal {
1526 StepCompensationStatus::Succeeded => Ok(StepAttemptStatus::Succeeded),
1527 StepCompensationStatus::Unknown => Ok(StepAttemptStatus::Unknown),
1528 StepCompensationStatus::Halted => Ok(StepAttemptStatus::Halted),
1529 StepCompensationStatus::None | StepCompensationStatus::Pending => {
1530 anyhow::bail!("compensation gate status is not replayable")
1531 }
1532 }
1533}
1534
1535fn resolution_to_attempt_status(status: StepResolutionStatus) -> anyhow::Result<StepAttemptStatus> {
1543 match status {
1544 StepResolutionStatus::Succeeded => Ok(StepAttemptStatus::Succeeded),
1545 StepResolutionStatus::Rejected => Ok(StepAttemptStatus::Rejected),
1546 StepResolutionStatus::Halted => Ok(StepAttemptStatus::Halted),
1547 StepResolutionStatus::None | StepResolutionStatus::Pending => {
1548 anyhow::bail!("resolution gate status is not replayable")
1549 }
1550 }
1551}