1use std::collections::{BTreeMap, BTreeSet};
7use std::time::Duration;
8
9use nasaga_core::{
10 CancelMode, Compensation, DefinitionVersion, ResolutionMode, ResolutionSpec, ServiceIdentity,
11 StepDefinition, StepName, TimeoutPolicy, WorkflowDefinition, WorkflowName,
12 OPAQUE_IDENTIFIER_MAX_BYTES, STRUCTURED_IDENTIFIER_MAX_BYTES,
13};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest as _, Sha256};
16use url::Url;
17
18use crate::DefinitionRegistry;
19
20pub const CATALOG_OBJECT_KEY_MAX_LEN: usize =
22 OPAQUE_IDENTIFIER_MAX_BYTES + STRUCTURED_IDENTIFIER_MAX_BYTES * 4 + 10 + 5;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum DefinitionLifecycle {
28 Candidate,
30 Active,
32 Deprecated,
34 Retired,
36}
37
38impl DefinitionLifecycle {
39 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Candidate => "candidate",
47 Self::Active => "active",
48 Self::Deprecated => "deprecated",
49 Self::Retired => "retired",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum DefinitionPublishDisposition {
58 Published,
60 Duplicate,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum DefinitionCatalogError {
68 InvalidArgument,
70 PermissionDenied,
72 NotFound,
74 FailedPrecondition,
76 DigestConflict,
78 PreconditionFailed,
80 OperationConflict,
82}
83
84impl DefinitionCatalogError {
85 pub fn from_error(error: &anyhow::Error) -> Option<Self> {
91 error
92 .chain()
93 .find_map(|cause| cause.downcast_ref::<Self>().copied())
94 }
95}
96
97impl std::fmt::Display for DefinitionCatalogError {
98 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 formatter.write_str(match self {
105 Self::InvalidArgument => "Saga Catalog request is invalid",
106 Self::PermissionDenied => "Saga Catalog actor is not authorized for this object",
107 Self::NotFound => "Saga definition was not found",
108 Self::FailedPrecondition => "Saga Catalog business precondition is not satisfied",
109 Self::DigestConflict => "Saga definition key has a different immutable digest",
110 Self::PreconditionFailed => "Saga definition digest no longer matches",
111 Self::OperationConflict => {
112 "Saga definition operation identity has different parameters"
113 }
114 })
115 }
116}
117
118impl std::error::Error for DefinitionCatalogError {}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "kebab-case")]
123pub enum DefinitionCompensation {
124 Compensable,
126 NonCompensable,
128}
129
130impl From<DefinitionCompensation> for Compensation {
131 fn from(value: DefinitionCompensation) -> Self {
137 match value {
138 DefinitionCompensation::Compensable => Self::Compensable,
139 DefinitionCompensation::NonCompensable => Self::NonCompensable,
140 }
141 }
142}
143
144impl From<Compensation> for DefinitionCompensation {
145 fn from(value: Compensation) -> Self {
151 match value {
152 Compensation::Compensable => Self::Compensable,
153 Compensation::NonCompensable => Self::NonCompensable,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "kebab-case")]
161pub enum DefinitionCancelMode {
162 LocalFenceable,
164 ExternallyCancellable,
166 ResolveOnly,
168}
169
170impl From<DefinitionCancelMode> for CancelMode {
171 fn from(value: DefinitionCancelMode) -> Self {
177 match value {
178 DefinitionCancelMode::LocalFenceable => Self::LocalFenceable,
179 DefinitionCancelMode::ExternallyCancellable => Self::ExternallyCancellable,
180 DefinitionCancelMode::ResolveOnly => Self::ResolveOnly,
181 }
182 }
183}
184
185impl From<CancelMode> for DefinitionCancelMode {
186 fn from(value: CancelMode) -> Self {
192 match value {
193 CancelMode::LocalFenceable => Self::LocalFenceable,
194 CancelMode::ExternallyCancellable => Self::ExternallyCancellable,
195 CancelMode::ResolveOnly => Self::ResolveOnly,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum DefinitionResolutionMode {
204 Callback,
206 Poll,
208 Manual,
210}
211
212impl From<DefinitionResolutionMode> for ResolutionMode {
213 fn from(value: DefinitionResolutionMode) -> Self {
219 match value {
220 DefinitionResolutionMode::Callback => Self::Callback,
221 DefinitionResolutionMode::Poll => Self::Poll,
222 DefinitionResolutionMode::Manual => Self::Manual,
223 }
224 }
225}
226
227impl From<ResolutionMode> for DefinitionResolutionMode {
228 fn from(value: ResolutionMode) -> Self {
234 match value {
235 ResolutionMode::Callback => Self::Callback,
236 ResolutionMode::Poll => Self::Poll,
237 ResolutionMode::Manual => Self::Manual,
238 }
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(untagged)]
245pub enum DefinitionResolution {
246 Forbidden(String),
248 Allowed {
250 mode: DefinitionResolutionMode,
252 budget_ms: u64,
254 },
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "kebab-case")]
260pub enum DefinitionTimeoutPolicy {
261 AcceptLateSuccess,
263 CompensateLateSuccess,
265}
266
267impl From<DefinitionTimeoutPolicy> for TimeoutPolicy {
268 fn from(value: DefinitionTimeoutPolicy) -> Self {
274 match value {
275 DefinitionTimeoutPolicy::AcceptLateSuccess => Self::AcceptLateSuccess,
276 DefinitionTimeoutPolicy::CompensateLateSuccess => Self::CompensateLateSuccess,
277 }
278 }
279}
280
281impl From<TimeoutPolicy> for DefinitionTimeoutPolicy {
282 fn from(value: TimeoutPolicy) -> Self {
288 match value {
289 TimeoutPolicy::AcceptLateSuccess => Self::AcceptLateSuccess,
290 TimeoutPolicy::CompensateLateSuccess => Self::CompensateLateSuccess,
291 }
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub struct DefinitionStepArtifact {
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub payload_contract: Option<nasaga_core::SagaPayloadContract>,
302 pub name: String,
304 pub owner: String,
306 pub compensation: DefinitionCompensation,
308 pub cancel_mode: DefinitionCancelMode,
310 pub resolution: DefinitionResolution,
312 pub timeout_ms: u64,
314 pub timeout_policy: DefinitionTimeoutPolicy,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct DefinitionArtifact {
322 pub tenant: String,
324 pub workflow_owner: String,
326 pub workflow: String,
328 pub definition_version: u32,
330 pub steps: Vec<DefinitionStepArtifact>,
332 pub seal: String,
334}
335
336impl DefinitionArtifact {
337 pub fn to_definition(&self) -> anyhow::Result<WorkflowDefinition> {
343 let workflow =
344 WorkflowName::new(&self.workflow).map_err(|error| anyhow::anyhow!(error.code()))?;
345 let version = DefinitionVersion::new(self.definition_version)
346 .map_err(|error| anyhow::anyhow!(error.code()))?;
347 let mut steps = Vec::with_capacity(self.steps.len());
348 for artifact in &self.steps {
349 let resolution = match &artifact.resolution {
350 DefinitionResolution::Forbidden(value) if value == "forbidden" => {
351 ResolutionSpec::forbidden()
352 }
353 DefinitionResolution::Forbidden(_) => {
354 anyhow::bail!("definition resolution text must be `forbidden`");
355 }
356 DefinitionResolution::Allowed { mode, budget_ms } => {
357 ResolutionSpec::allowed((*mode).into(), Duration::from_millis(*budget_ms))
358 }
359 };
360 steps.push(
361 StepDefinition::new(
362 StepName::new(&artifact.name).map_err(|error| anyhow::anyhow!(error.code()))?,
363 ServiceIdentity::new(&artifact.owner)
364 .map_err(|error| anyhow::anyhow!(error.code()))?,
365 artifact.compensation.into(),
366 artifact.cancel_mode.into(),
367 resolution,
368 Duration::from_millis(artifact.timeout_ms),
369 artifact.timeout_policy.into(),
370 )
371 .with_payload_contract(artifact.payload_contract.clone().unwrap_or_default())?,
372 );
373 }
374 let definition = WorkflowDefinition::new(workflow, version, steps)
375 .map_err(|error| anyhow::anyhow!(error.code()))?;
376 if definition.digest() != self.seal {
377 anyhow::bail!("definition seal does not match canonical workflow content");
378 }
379 Ok(definition)
380 }
381
382 pub fn from_definition(
388 tenant: impl Into<String>,
389 workflow_owner: impl Into<String>,
390 definition: &WorkflowDefinition,
391 ) -> Self {
392 let steps = definition
393 .steps()
394 .iter()
395 .map(|step| DefinitionStepArtifact {
396 payload_contract: (step.payload_contract()
397 != &nasaga_core::SagaPayloadContract::default())
398 .then(|| step.payload_contract().clone()),
399 name: step.name().as_str().to_owned(),
400 owner: step.owner().as_str().to_owned(),
401 compensation: step.compensation().into(),
402 cancel_mode: step.cancel_mode().into(),
403 resolution: match step.resolution().mode() {
404 Some(mode) => DefinitionResolution::Allowed {
405 mode: mode.into(),
406 budget_ms: u64::try_from(step.resolution().budget().as_millis())
407 .unwrap_or(u64::MAX),
408 },
409 None => DefinitionResolution::Forbidden("forbidden".to_owned()),
410 },
411 timeout_ms: u64::try_from(step.timeout().as_millis()).unwrap_or(u64::MAX),
412 timeout_policy: step.timeout_policy().into(),
413 })
414 .collect();
415 Self {
416 tenant: tenant.into(),
417 workflow_owner: workflow_owner.into(),
418 workflow: definition.name().as_str().to_owned(),
419 definition_version: definition.version().get(),
420 steps,
421 seal: definition.digest(),
422 }
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct DefinitionRecord {
430 pub artifact: DefinitionArtifact,
432 pub lifecycle: DefinitionLifecycle,
434 pub catalog_generation: u64,
436 pub published_at_ms: i64,
438 pub activated_at_ms: Option<i64>,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct DefinitionActivationGate {
445 orchestrator_service_identity: ServiceIdentity,
446 catalog_generation: u64,
447 snapshot_digest: String,
448 transport: String,
449 redis_key_tag: Option<String>,
450 address_policy_digest: Option<String>,
451 publisher_contract_digest: Option<String>,
452 result_backend_digest: Option<String>,
453 result_contract_digests: BTreeMap<String, BTreeSet<String>>,
454 activation_contract_digest: String,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct DefinitionLifecycleOperation {
460 expected_sha256: String,
461 operation_id: String,
462 reason: String,
463}
464
465impl DefinitionLifecycleOperation {
466 pub fn new(
473 expected_sha256: impl Into<String>,
474 operation_id: impl Into<String>,
475 reason: impl Into<String>,
476 ) -> anyhow::Result<Self> {
477 let expected_sha256 = expected_sha256.into();
478 let operation_id = operation_id.into();
479 let reason = reason.into();
480 anyhow::ensure!(
481 expected_sha256.len() == 64
482 && expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()),
483 "definition expected digest is invalid"
484 );
485 anyhow::ensure!(
486 !operation_id.is_empty()
487 && operation_id == operation_id.trim()
488 && operation_id.len() <= 190
489 && !operation_id.chars().any(char::is_control),
490 "definition operation identity is invalid"
491 );
492 anyhow::ensure!(
493 !reason.is_empty()
494 && reason == reason.trim()
495 && reason.len() <= 512
496 && !reason.chars().any(char::is_control),
497 "definition operation reason is invalid"
498 );
499 Ok(Self {
500 expected_sha256: expected_sha256.to_ascii_lowercase(),
501 operation_id,
502 reason,
503 })
504 }
505
506 pub fn for_validated_activation(
512 artifact: &DefinitionArtifact,
513 catalog_generation: u64,
514 reason: impl Into<String>,
515 ) -> anyhow::Result<Self> {
516 let mut canonical = Vec::new();
517 for field in [
518 b"nasaga-validated-activation".as_slice(),
519 artifact.seal.as_bytes(),
520 artifact.tenant.as_bytes(),
521 artifact.workflow.as_bytes(),
522 ] {
523 canonical.extend_from_slice(&(field.len() as u64).to_be_bytes());
524 canonical.extend_from_slice(field);
525 }
526 canonical.extend_from_slice(&artifact.definition_version.to_be_bytes());
527 canonical.extend_from_slice(&catalog_generation.to_be_bytes());
528 let mut hasher = Sha256::new();
529 hasher.update(canonical);
530 Self::new(
531 artifact.seal.clone(),
532 format!("validated-{}", hex::encode(hasher.finalize())),
533 reason,
534 )
535 }
536
537 pub fn expected_sha256(&self) -> &str {
543 &self.expected_sha256
544 }
545
546 pub fn operation_id(&self) -> &str {
552 &self.operation_id
553 }
554
555 pub fn reason(&self) -> &str {
561 &self.reason
562 }
563}
564
565impl DefinitionActivationGate {
566 pub fn new(
573 orchestrator_service_identity: ServiceIdentity,
574 catalog_generation: u64,
575 snapshot_digest: impl Into<String>,
576 transport: impl Into<String>,
577 ) -> anyhow::Result<Self> {
578 let snapshot_digest = snapshot_digest.into();
579 anyhow::ensure!(
580 snapshot_digest.len() == 64
581 && snapshot_digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
582 "catalog snapshot digest is invalid"
583 );
584 let transport = transport.into();
585 anyhow::ensure!(
586 matches!(
587 transport.as_str(),
588 "http" | "grpc" | "kafka" | "redis-stream"
589 ),
590 "definition activation transport is unsupported"
591 );
592 let mut gate = Self {
593 orchestrator_service_identity,
594 catalog_generation,
595 snapshot_digest,
596 transport,
597 redis_key_tag: None,
598 address_policy_digest: None,
599 publisher_contract_digest: None,
600 result_backend_digest: None,
601 result_contract_digests: BTreeMap::new(),
602 activation_contract_digest: String::new(),
603 };
604 gate.refresh_activation_contract_digest();
605 Ok(gate)
606 }
607
608 pub fn with_redis_key_tag(mut self, key_tag: impl Into<String>) -> anyhow::Result<Self> {
615 let key_tag = key_tag.into();
616 anyhow::ensure!(
617 self.transport == "redis-stream" && valid_redis_key_tag(&key_tag),
618 "definition activation Redis key tag is invalid"
619 );
620 self.redis_key_tag = Some(key_tag);
621 self.refresh_activation_contract_digest();
622 Ok(self)
623 }
624
625 pub fn with_address_policy_digest(mut self, digest: impl Into<String>) -> anyhow::Result<Self> {
631 let digest = digest.into();
632 anyhow::ensure!(
633 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
634 "definition activation address policy digest is invalid"
635 );
636 self.address_policy_digest = Some(digest.to_ascii_lowercase());
637 self.refresh_activation_contract_digest();
638 Ok(self)
639 }
640
641 pub fn with_publisher_contract_digest(
648 mut self,
649 digest: impl Into<String>,
650 ) -> anyhow::Result<Self> {
651 let digest = digest.into();
652 anyhow::ensure!(
653 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
654 "definition activation publisher contract digest is invalid"
655 );
656 self.publisher_contract_digest = Some(digest.to_ascii_lowercase());
657 self.refresh_activation_contract_digest();
658 Ok(self)
659 }
660
661 pub fn with_result_backend_digest(mut self, digest: impl Into<String>) -> anyhow::Result<Self> {
668 let digest = digest.into();
669 anyhow::ensure!(
670 self.transport == "kafka"
671 && digest.len() == 64
672 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
673 "definition activation result backend digest is invalid"
674 );
675 self.result_backend_digest = Some(digest.to_ascii_lowercase());
676 self.refresh_activation_contract_digest();
677 Ok(self)
678 }
679
680 pub fn with_result_contract_digest(
686 mut self,
687 owner: impl Into<String>,
688 digest: impl Into<String>,
689 ) -> anyhow::Result<Self> {
690 let owner = owner.into();
691 ServiceIdentity::new(&owner)?;
692 let digest = digest.into();
693 anyhow::ensure!(
694 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()),
695 "definition activation result contract digest is invalid"
696 );
697 self.result_contract_digests
698 .entry(owner)
699 .or_default()
700 .insert(digest.to_ascii_lowercase());
701 self.refresh_activation_contract_digest();
702 Ok(self)
703 }
704
705 fn refresh_activation_contract_digest(&mut self) {
711 let mut digest = Sha256::new();
712 update_snapshot_digest(&mut digest, b"nasaga-activation-contract");
713 update_snapshot_digest(&mut digest, self.transport.as_bytes());
714 match self.redis_key_tag.as_deref() {
715 Some(key_tag) => {
716 digest.update([1]);
717 update_snapshot_digest(&mut digest, key_tag.as_bytes());
718 }
719 None => digest.update([0]),
720 }
721 match self.address_policy_digest.as_deref() {
722 Some(policy_digest) => {
723 digest.update([1]);
724 update_snapshot_digest(&mut digest, policy_digest.as_bytes());
725 }
726 None => digest.update([0]),
727 }
728 match self.publisher_contract_digest.as_deref() {
729 Some(publisher_digest) => {
730 digest.update([1]);
731 update_snapshot_digest(&mut digest, publisher_digest.as_bytes());
732 }
733 None => digest.update([0]),
734 }
735 match self.result_backend_digest.as_deref() {
736 Some(result_backend_digest) => {
737 digest.update([1]);
738 update_snapshot_digest(&mut digest, result_backend_digest.as_bytes());
739 }
740 None => digest.update([0]),
741 }
742 digest.update((self.result_contract_digests.len() as u64).to_be_bytes());
743 for (owner, contracts) in &self.result_contract_digests {
744 update_snapshot_digest(&mut digest, owner.as_bytes());
745 digest.update((contracts.len() as u64).to_be_bytes());
746 for contract in contracts {
747 update_snapshot_digest(&mut digest, contract.as_bytes());
748 }
749 }
750 self.activation_contract_digest = hex::encode(digest.finalize());
751 }
752
753 pub fn orchestrator_service_identity(&self) -> &ServiceIdentity {
759 &self.orchestrator_service_identity
760 }
761
762 pub fn catalog_generation(&self) -> u64 {
768 self.catalog_generation
769 }
770
771 pub fn snapshot_digest(&self) -> &str {
777 &self.snapshot_digest
778 }
779
780 pub fn transport(&self) -> &str {
786 &self.transport
787 }
788
789 pub fn redis_key_tag(&self) -> Option<&str> {
795 self.redis_key_tag.as_deref()
796 }
797
798 pub fn result_backend_digest(&self) -> Option<&str> {
804 self.result_backend_digest.as_deref()
805 }
806
807 pub fn accepts_result_contract(&self, owner: &str, digest: &str) -> bool {
813 self.result_contract_digests
814 .get(owner)
815 .is_some_and(|contracts| contracts.contains(digest))
816 }
817
818 pub fn activation_contract_digest(&self) -> &str {
824 &self.activation_contract_digest
825 }
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830#[serde(deny_unknown_fields)]
831pub struct CapabilityDescriptor {
832 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub payload_contract: Option<nasaga_core::SagaPayloadContract>,
835 pub tenant: String,
837 pub owner: String,
839 pub replica_identity: String,
841 pub workflow: String,
843 pub definition_version: u32,
845 pub step: String,
847 pub compensation: DefinitionCompensation,
849 pub cancel_mode: DefinitionCancelMode,
851 pub allow_unknown: bool,
853 pub resolution_mode: Option<DefinitionResolutionMode>,
855 pub transport: String,
857 pub endpoint: String,
859 pub effective_saga_base_path: Option<String>,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub result_contract_digest: Option<String>,
864 pub route_generation: u64,
866 pub requested_lease_ms: u64,
868}
869
870impl CapabilityDescriptor {
871 pub fn digest(&self) -> String {
877 let mut canonical = self.clone();
878 canonical.requested_lease_ms = 0;
879 let bytes = serde_json::to_vec(&canonical)
880 .expect("serializing a capability descriptor cannot fail");
881 let mut hasher = Sha256::new();
882 hasher.update(bytes);
883 hex::encode(hasher.finalize())
884 }
885
886 pub fn route_contract_digest(&self) -> String {
892 let mut canonical = self.clone();
893 canonical.route_generation = 0;
894 canonical.requested_lease_ms = 0;
895 let bytes = serde_json::to_vec(&canonical)
896 .expect("serializing a capability descriptor cannot fail");
897 let mut hasher = Sha256::new();
898 hasher.update(bytes);
899 hex::encode(hasher.finalize())
900 }
901
902 pub fn matches_step(
908 &self,
909 tenant: &str,
910 definition: &WorkflowDefinition,
911 step: &StepDefinition,
912 ) -> bool {
913 self.payload_contract.clone().unwrap_or_default() == *step.payload_contract()
914 && self.tenant == tenant
915 && self.workflow == definition.name().as_str()
916 && self.definition_version == definition.version().get()
917 && self.step == step.name().as_str()
918 && self.owner == step.owner().as_str()
919 && self.compensation == step.compensation().into()
920 && self.cancel_mode == step.cancel_mode().into()
921 && self.allow_unknown == step.resolution().allow_unknown()
922 && self.resolution_mode == step.resolution().mode().map(Into::into)
923 }
924}
925
926pub fn validate_capability_route_contract(
934 descriptor: &CapabilityDescriptor,
935) -> Result<(), DefinitionCatalogError> {
936 descriptor
937 .payload_contract
938 .clone()
939 .unwrap_or_default()
940 .validate()
941 .map_err(|_| DefinitionCatalogError::InvalidArgument)?;
942 let path_contract_is_valid = match descriptor.transport.as_str() {
943 "http" => descriptor
944 .effective_saga_base_path
945 .as_deref()
946 .is_some_and(valid_capability_http_base_path),
947 "grpc" | "kafka" | "redis-stream" => descriptor.effective_saga_base_path.is_none(),
948 _ => false,
949 };
950 if !path_contract_is_valid {
951 return Err(DefinitionCatalogError::InvalidArgument);
952 }
953 let result_contract_is_valid = match descriptor.transport.as_str() {
954 "http" | "grpc" | "kafka" | "redis-stream" => descriptor
955 .result_contract_digest
956 .as_deref()
957 .is_some_and(|digest| {
958 digest.len() == 64
959 && digest == digest.to_ascii_lowercase()
960 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
961 }),
962 _ => false,
963 };
964 if !result_contract_is_valid {
965 return Err(DefinitionCatalogError::InvalidArgument);
966 }
967
968 let endpoint_is_valid = match descriptor.transport.as_str() {
969 "http" => valid_capability_origin(&descriptor.endpoint, &["http", "https"]),
970 "grpc" => valid_capability_origin(&descriptor.endpoint, &["https"]),
971 "kafka" => {
972 !descriptor.endpoint.is_empty()
973 && descriptor.endpoint.len() <= 249
974 && descriptor.endpoint.trim() == descriptor.endpoint
975 && !matches!(descriptor.endpoint.as_str(), "." | "..")
976 && descriptor
977 .endpoint
978 .bytes()
979 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
980 }
981 "redis-stream" => validate_redis_stream_route(&descriptor.endpoint, None).is_ok(),
982 _ => false,
983 };
984 if endpoint_is_valid {
985 Ok(())
986 } else {
987 Err(DefinitionCatalogError::InvalidArgument)
988 }
989}
990
991fn valid_capability_origin(endpoint: &str, schemes: &[&str]) -> bool {
997 let raw_origin_is_canonical = endpoint
998 .split_once("://")
999 .is_some_and(|(scheme, authority)| {
1000 schemes.contains(&scheme)
1001 && !authority.is_empty()
1002 && (authority.bytes().all(|byte| byte != b'/')
1003 || (authority.ends_with('/')
1004 && authority[..authority.len() - 1]
1005 .bytes()
1006 .all(|byte| byte != b'/')))
1007 });
1008 Url::parse(endpoint).is_ok_and(|url| {
1009 endpoint.trim() == endpoint
1010 && raw_origin_is_canonical
1011 && schemes.contains(&url.scheme())
1012 && url.host_str().is_some_and(|host| !host.is_empty())
1013 && url.username().is_empty()
1014 && url.password().is_none()
1015 && url.query().is_none()
1016 && url.fragment().is_none()
1017 && matches!(url.path(), "" | "/")
1018 })
1019}
1020
1021pub fn validate_redis_stream_route(
1027 stream: &str,
1028 key_tag: Option<&str>,
1029) -> Result<(), DefinitionCatalogError> {
1030 let valid = !stream.is_empty()
1031 && stream.len() <= 190
1032 && !stream.chars().any(char::is_control)
1033 && key_tag.is_none_or(|tag| {
1034 valid_redis_key_tag(tag) && first_redis_hash_tag(stream) == Some(tag)
1035 });
1036 if valid {
1037 Ok(())
1038 } else {
1039 Err(DefinitionCatalogError::InvalidArgument)
1040 }
1041}
1042
1043fn valid_redis_key_tag(tag: &str) -> bool {
1049 !tag.is_empty()
1050 && tag.len() <= 64
1051 && !tag.contains(['{', '}'])
1052 && !tag.chars().any(char::is_control)
1053}
1054
1055fn first_redis_hash_tag(name: &str) -> Option<&str> {
1062 let open = name.find('{')?;
1063 let close = name[open + 1..].find('}')?;
1064 if close == 0 {
1065 return None;
1066 }
1067 Some(&name[open + 1..open + 1 + close])
1068}
1069
1070fn valid_capability_http_base_path(path: &str) -> bool {
1076 path != "/"
1077 && path.starts_with('/')
1078 && !path.ends_with('/')
1079 && path.bytes().all(|byte| {
1080 byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~')
1081 })
1082 && !path.contains("//")
1083 && !path.contains('%')
1084 && !path.contains('?')
1085 && !path.contains('#')
1086 && !path.contains('*')
1087 && !path.contains('{')
1088 && !path.contains('}')
1089 && !path.split('/').any(|segment| matches!(segment, "." | ".."))
1090}
1091
1092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1094#[serde(deny_unknown_fields)]
1095pub struct CapabilityReceipt {
1096 pub accepted_until_ms: i64,
1098 pub capability_digest: String,
1100 pub route_generation: u64,
1102 pub catalog_generation: u64,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Eq)]
1108pub struct RegisteredCapability {
1109 pub descriptor: CapabilityDescriptor,
1111 pub accepted_until_ms: i64,
1113 pub capability_digest: String,
1115}
1116
1117pub fn select_capability_route<'a>(
1130 capabilities: &'a [RegisteredCapability],
1131 transport: &str,
1132 tenant: &str,
1133 definition: &WorkflowDefinition,
1134 step: &StepDefinition,
1135) -> Result<Option<&'a CapabilityDescriptor>, DefinitionCatalogError> {
1136 let mut selected: Option<&CapabilityDescriptor> = None;
1137 for descriptor in capabilities
1138 .iter()
1139 .map(|capability| &capability.descriptor)
1140 .filter(|descriptor| {
1141 descriptor.transport == transport && descriptor.matches_step(tenant, definition, step)
1142 })
1143 {
1144 validate_capability_route_contract(descriptor)?;
1145 if selected.is_some_and(|current| {
1146 current.endpoint != descriptor.endpoint
1147 || current.effective_saga_base_path != descriptor.effective_saga_base_path
1148 || (transport == "kafka"
1149 && current.result_contract_digest != descriptor.result_contract_digest)
1150 }) {
1151 return Err(DefinitionCatalogError::FailedPrecondition);
1152 }
1153 selected.get_or_insert(descriptor);
1154 }
1155 Ok(selected)
1156}
1157
1158pub fn select_capability_routes<'a>(
1162 capabilities: &'a [RegisteredCapability],
1163 transport: &str,
1164 tenant: &str,
1165 definition: &WorkflowDefinition,
1166 step: &StepDefinition,
1167) -> Result<Vec<&'a CapabilityDescriptor>, DefinitionCatalogError> {
1168 if !matches!(transport, "http" | "grpc") {
1169 return select_capability_route(capabilities, transport, tenant, definition, step)
1170 .map(|route| route.into_iter().collect());
1171 }
1172 let mut routes: Vec<&CapabilityDescriptor> = Vec::new();
1173 for descriptor in capabilities
1174 .iter()
1175 .map(|capability| &capability.descriptor)
1176 .filter(|descriptor| {
1177 descriptor.transport == transport && descriptor.matches_step(tenant, definition, step)
1178 })
1179 {
1180 validate_capability_route_contract(descriptor)?;
1181 if routes.iter().any(|current| {
1183 current.replica_identity == descriptor.replica_identity && *current != descriptor
1184 }) {
1185 return Err(DefinitionCatalogError::FailedPrecondition);
1186 }
1187 routes.push(descriptor);
1188 }
1189 routes.sort_by(|left, right| {
1190 (&left.endpoint, &left.replica_identity).cmp(&(&right.endpoint, &right.replica_identity))
1191 });
1192 routes.dedup();
1193 Ok(routes)
1194}
1195
1196#[derive(Debug, Clone)]
1198pub struct DynamicCatalogSnapshot {
1199 pub generation: u64,
1201 pub snapshot_digest: String,
1203 pub registry: DefinitionRegistry,
1205 pub definition_records: Vec<DefinitionRecord>,
1207 pub capabilities: Vec<RegisteredCapability>,
1209}
1210
1211impl DynamicCatalogSnapshot {
1212 pub fn new(
1218 generation: u64,
1219 registry: DefinitionRegistry,
1220 capabilities: Vec<RegisteredCapability>,
1221 ) -> Self {
1222 Self::with_definition_records(generation, registry, Vec::new(), capabilities)
1223 }
1224
1225 pub fn with_definition_records(
1232 generation: u64,
1233 registry: DefinitionRegistry,
1234 mut definition_records: Vec<DefinitionRecord>,
1235 mut capabilities: Vec<RegisteredCapability>,
1236 ) -> Self {
1237 definition_records.sort_by(|left, right| {
1238 (
1239 &left.artifact.tenant,
1240 &left.artifact.workflow,
1241 left.artifact.definition_version,
1242 )
1243 .cmp(&(
1244 &right.artifact.tenant,
1245 &right.artifact.workflow,
1246 right.artifact.definition_version,
1247 ))
1248 });
1249 capabilities.sort_by(|left, right| {
1250 (
1251 &left.descriptor.tenant,
1252 &left.descriptor.owner,
1253 &left.descriptor.replica_identity,
1254 &left.descriptor.workflow,
1255 left.descriptor.definition_version,
1256 &left.descriptor.step,
1257 )
1258 .cmp(&(
1259 &right.descriptor.tenant,
1260 &right.descriptor.owner,
1261 &right.descriptor.replica_identity,
1262 &right.descriptor.workflow,
1263 right.descriptor.definition_version,
1264 &right.descriptor.step,
1265 ))
1266 });
1267 let mut digest = Sha256::new();
1268 digest.update(generation.to_be_bytes());
1269 let mut runtime_definitions = registry
1271 .definitions()
1272 .map(|definition| {
1273 (
1274 definition.name().as_str(),
1275 definition.version().get(),
1276 definition.digest(),
1277 )
1278 })
1279 .collect::<Vec<_>>();
1280 runtime_definitions.sort();
1281 for (workflow, version, definition_digest) in runtime_definitions {
1282 update_snapshot_digest(&mut digest, workflow.as_bytes());
1283 digest.update(version.to_be_bytes());
1284 update_snapshot_digest(&mut digest, definition_digest.as_bytes());
1285 }
1286 for record in &definition_records {
1287 update_snapshot_digest(&mut digest, record.artifact.tenant.as_bytes());
1288 update_snapshot_digest(&mut digest, record.artifact.workflow_owner.as_bytes());
1289 update_snapshot_digest(&mut digest, record.artifact.workflow.as_bytes());
1290 digest.update(record.artifact.definition_version.to_be_bytes());
1291 update_snapshot_digest(&mut digest, record.lifecycle.as_str().as_bytes());
1292 update_snapshot_digest(&mut digest, record.artifact.seal.as_bytes());
1293 digest.update(record.catalog_generation.to_be_bytes());
1294 }
1295 for capability in &capabilities {
1296 update_snapshot_digest(&mut digest, capability.descriptor.tenant.as_bytes());
1297 update_snapshot_digest(&mut digest, capability.descriptor.owner.as_bytes());
1298 update_snapshot_digest(
1299 &mut digest,
1300 capability.descriptor.replica_identity.as_bytes(),
1301 );
1302 update_snapshot_digest(&mut digest, capability.descriptor.workflow.as_bytes());
1303 digest.update(capability.descriptor.definition_version.to_be_bytes());
1304 update_snapshot_digest(&mut digest, capability.descriptor.step.as_bytes());
1305 update_snapshot_digest(&mut digest, capability.capability_digest.as_bytes());
1306 }
1307 Self {
1308 generation,
1309 snapshot_digest: hex::encode(digest.finalize()),
1310 registry,
1311 definition_records,
1312 capabilities,
1313 }
1314 }
1315}
1316
1317fn update_snapshot_digest(digest: &mut Sha256, value: &[u8]) {
1323 digest.update((value.len() as u64).to_be_bytes());
1324 digest.update(value);
1325}