1use af_context::{InstanceId, RunId, SubjectId, TenantId};
8use std::collections::{BTreeMap, BTreeSet};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct WorkflowDefinition {
17 pub id: String,
19 pub name: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct WorkflowInstance {
26 pub id: String,
28 pub tenant_id: TenantId,
30 pub subject_id: SubjectId,
32 pub definition_id: String,
34 pub revision: u64,
36 pub execution_profile_id: String,
38 pub execution_profile_revision: u64,
40 pub lifecycle: LifecyclePolicy,
42 pub status: String,
44 pub state_version: i64,
46 pub event_sequence: i64,
48 pub control_epoch: i64,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct TriggerBinding {
55 pub id: String,
57 pub revision: u64,
59 pub source: String,
61 pub event_type: String,
63 pub instance_id: InstanceId,
65 #[serde(default)]
67 pub branch_id: Option<af_context::BranchId>,
68 pub predicate: Value,
70 pub ordering: OrderingPolicy,
72 pub starts_at: Option<DateTime<Utc>>,
74 pub expires_at: Option<DateTime<Utc>>,
76 #[serde(default = "default_gap_wait_ms")]
78 pub gap_wait_ms: u64,
79 #[serde(default = "default_gap_limit")]
81 pub gap_limit: u32,
82}
83
84impl TriggerBinding {
85 pub fn validate(&self) -> Result<(), ContractError> {
87 for (name, value) in [
88 ("trigger binding id", self.id.as_str()),
89 ("trigger source", self.source.as_str()),
90 ("trigger event_type", self.event_type.as_str()),
91 ("trigger instance_id", self.instance_id.as_str()),
92 ] {
93 required(name, value)?;
94 }
95 if self.gap_wait_ms == 0 || self.gap_wait_ms > 3_600_000 {
96 return Err(ContractError::Invalid(
97 "trigger gap_wait_ms must be between 1 and 3600000".into(),
98 ));
99 }
100 if self.gap_limit == 0 || self.gap_limit > 10_000 {
101 return Err(ContractError::Invalid(
102 "trigger gap_limit must be between 1 and 10000".into(),
103 ));
104 }
105 if !self.predicate.is_object() {
106 return Err(ContractError::Invalid(
107 "trigger predicate must be a JSON object".into(),
108 ));
109 }
110 if matches!((self.starts_at, self.expires_at), (Some(start), Some(end)) if end <= start) {
111 return Err(ContractError::Invalid(
112 "trigger expires_at must be after starts_at".into(),
113 ));
114 }
115 Ok(())
116 }
117}
118
119const fn default_gap_wait_ms() -> u64 {
120 30_000
121}
122
123const fn default_gap_limit() -> u32 {
124 100
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct WorkflowEvent {
130 pub instance_id: InstanceId,
132 pub sequence: i64,
134 pub event_type: String,
136 pub payload: Value,
138 pub content_digest: String,
140 pub occurred_at: DateTime<Utc>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ExecutionMode {
148 Simulation,
150 Backtest,
152 Paper,
154 Live,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum DurabilityGrade {
162 Standard,
164 FundsGrade,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct ExecutionProfileRevision {
171 pub id: String,
173 pub revision: u64,
175 pub content_digest: String,
177 pub mode: ExecutionMode,
179 pub durability_grade: DurabilityGrade,
181 pub trigger_provider: String,
183 pub data_provider: String,
185 pub clock_model: String,
187 pub action_provider: String,
189 #[serde(default)]
191 pub models: BTreeMap<String, Value>,
192 #[serde(default)]
194 pub environment: Value,
195 #[serde(default)]
197 pub policy_bundle: Value,
198 #[serde(default)]
200 pub connection_bindings: BTreeMap<String, String>,
201}
202
203impl ExecutionProfileRevision {
204 pub fn validate(&self) -> Result<(), ContractError> {
206 for (name, value) in [
207 ("execution profile id", self.id.as_str()),
208 (
209 "execution profile content_digest",
210 self.content_digest.as_str(),
211 ),
212 ("trigger_provider", self.trigger_provider.as_str()),
213 ("data_provider", self.data_provider.as_str()),
214 ("clock_model", self.clock_model.as_str()),
215 ("action_provider", self.action_provider.as_str()),
216 ] {
217 required(name, value)?;
218 }
219 Ok(())
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
225pub struct CapabilityPin {
226 pub id: String,
228 pub contract_version: String,
230 pub content_digest: String,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct WorkflowRevision {
237 pub definition_id: String,
239 pub revision: u64,
241 pub content_digest: String,
243 pub kernel_abi_version: String,
245 pub dependency_set_digest: String,
247 #[serde(default)]
249 pub expression_versions: BTreeMap<String, String>,
250 #[serde(default)]
252 pub capabilities: Vec<CapabilityPin>,
253 #[serde(default)]
255 pub template_provenance: Value,
256 pub spec: crate::Spec,
258}
259
260impl WorkflowRevision {
261 pub fn validate(&self) -> Result<(), ContractError> {
263 required("definition_id", &self.definition_id)?;
264 required("content_digest", &self.content_digest)?;
265 required("kernel_abi_version", &self.kernel_abi_version)?;
266 required("dependency_set_digest", &self.dependency_set_digest)?;
267 self.spec
268 .validate_structure()
269 .map_err(|error| ContractError::Invalid(error.to_string()))?;
270 let unique = self
271 .capabilities
272 .iter()
273 .map(|pin| &pin.id)
274 .collect::<BTreeSet<_>>();
275 if unique.len() != self.capabilities.len() {
276 return Err(ContractError::Invalid(
277 "capability pins must be unique by id within one revision".into(),
278 ));
279 }
280 for pin in &self.capabilities {
281 required("capability id", &pin.id)?;
282 required("capability contract_version", &pin.contract_version)?;
283 required("capability content_digest", &pin.content_digest)?;
284 }
285 Ok(())
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum CapabilityKind {
293 Trigger,
295 Expression,
297 Guard,
299 Action,
301 Sink,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum Effect {
309 Pure,
311 Read,
313 InternalWrite,
315 ExternalWrite,
317 Funds,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum IdempotencyMode {
325 None,
327 Native,
329 ReconcileBeforeRetry,
331 NeverAutomaticRetry,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum CapabilityLifecycle {
339 Installed,
341 Active,
343 Deprecated,
345 Disabled,
347 Unavailable,
349 EmergencyRevoked,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct RetryPolicy {
356 pub max_attempts: u32,
358 pub timeout_ms: u64,
360 pub initial_backoff_ms: u64,
362 pub max_backoff_ms: u64,
364}
365
366impl RetryPolicy {
367 pub fn backoff_ms(&self, attempt: u32, jitter_seed: u64) -> u64 {
370 let factor = 1_u64
371 .checked_shl(attempt.saturating_sub(1).min(20))
372 .unwrap_or(u64::MAX);
373 let base = self
374 .initial_backoff_ms
375 .saturating_mul(factor)
376 .min(self.max_backoff_ms);
377 let jitter_ceiling = (base / 4).max(1);
378 base.saturating_add(jitter_seed % jitter_ceiling)
379 .min(self.max_backoff_ms)
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct CapabilityManifest {
386 pub id: String,
388 pub contract_version: String,
390 pub content_digest: String,
392 pub kind: CapabilityKind,
394 #[serde(default = "object_schema")]
396 pub config_schema: Value,
397 pub input_schema: Value,
399 pub output_schema: Value,
401 pub effect: Effect,
403 pub deterministic: bool,
405 pub idempotency_mode: IdempotencyMode,
407 pub retry: RetryPolicy,
409 #[serde(default)]
411 pub permissions: BTreeSet<String>,
412 #[serde(default)]
414 pub required_guards: BTreeSet<GuardKind>,
415 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
417 pub required_guard_pins: BTreeSet<CapabilityPin>,
418 #[serde(default)]
420 pub taint_rules: Value,
421 #[serde(default)]
423 pub resource_cost: Value,
424 pub supports_simulation: bool,
426 pub supports_replay: bool,
428 pub supports_paper: bool,
430 pub supports_live: bool,
432 #[serde(default)]
434 pub clock_requirements: Value,
435 #[serde(default)]
437 pub data_requirements: Value,
438 pub supports_reconciliation: bool,
440 pub lifecycle: CapabilityLifecycle,
442}
443
444fn object_schema() -> Value {
445 serde_json::json!({"type":"object"})
446}
447
448impl CapabilityManifest {
449 pub fn action(
451 id: impl Into<String>,
452 contract_version: impl Into<String>,
453 content_digest: impl Into<String>,
454 effect: Effect,
455 idempotency_mode: IdempotencyMode,
456 supports_reconciliation: bool,
457 ) -> Self {
458 let required_guards = match effect {
459 Effect::Funds => BTreeSet::from([
460 GuardKind::Authorization,
461 GuardKind::Freshness,
462 GuardKind::Reservation,
463 ]),
464 Effect::ExternalWrite => BTreeSet::from([GuardKind::Authorization]),
465 _ => BTreeSet::new(),
466 };
467 Self {
468 id: id.into(),
469 contract_version: contract_version.into(),
470 content_digest: content_digest.into(),
471 kind: CapabilityKind::Action,
472 config_schema: object_schema(),
473 input_schema: serde_json::json!({"type": "object"}),
474 output_schema: serde_json::json!({"type": "object"}),
475 effect,
476 deterministic: false,
477 idempotency_mode,
478 retry: RetryPolicy {
479 max_attempts: 1,
480 timeout_ms: 30_000,
481 initial_backoff_ms: 100,
482 max_backoff_ms: 5_000,
483 },
484 permissions: BTreeSet::new(),
485 required_guards,
486 required_guard_pins: BTreeSet::new(),
487 taint_rules: Value::Null,
488 resource_cost: Value::Null,
489 supports_simulation: true,
490 supports_replay: false,
491 supports_paper: true,
492 supports_live: true,
493 clock_requirements: Value::Null,
494 data_requirements: Value::Null,
495 supports_reconciliation,
496 lifecycle: CapabilityLifecycle::Active,
497 }
498 }
499
500 pub fn validate(&self) -> Result<(), ContractError> {
502 required("capability id", &self.id)?;
503 required("contract_version", &self.contract_version)?;
504 required("content_digest", &self.content_digest)?;
505 for pin in &self.required_guard_pins {
506 required("required guard id", &pin.id)?;
507 required("required guard contract_version", &pin.contract_version)?;
508 required("required guard content_digest", &pin.content_digest)?;
509 }
510 if !self.config_schema.is_object()
511 || jsonschema::validator_for(&self.config_schema).is_err()
512 {
513 return Err(ContractError::Invalid(
514 "capability config_schema must be a valid JSON Schema object".into(),
515 ));
516 }
517 if self.retry.max_attempts == 0
518 || self.retry.timeout_ms == 0
519 || self.retry.initial_backoff_ms == 0
520 || self.retry.max_backoff_ms < self.retry.initial_backoff_ms
521 {
522 return Err(ContractError::Invalid(
523 "retry attempts, timeout and backoff bounds are invalid".into(),
524 ));
525 }
526 if self.effect == Effect::Funds {
527 if self.kind != CapabilityKind::Action {
528 return Err(ContractError::Invalid(
529 "funds effect is only valid for action capabilities".into(),
530 ));
531 }
532 if !self.supports_reconciliation && self.idempotency_mode != IdempotencyMode::Native {
533 return Err(ContractError::Invalid(
534 "funds actions require native idempotency or reconciliation".into(),
535 ));
536 }
537 for guard in [
538 GuardKind::Authorization,
539 GuardKind::Freshness,
540 GuardKind::Reservation,
541 ] {
542 if !self.required_guards.contains(&guard) {
543 return Err(ContractError::Invalid(format!(
544 "funds action must require {guard:?} guard"
545 )));
546 }
547 }
548 }
549 Ok(())
550 }
551
552 pub fn can_start_new_work(&self) -> bool {
554 matches!(
555 self.lifecycle,
556 CapabilityLifecycle::Installed
557 | CapabilityLifecycle::Active
558 | CapabilityLifecycle::Deprecated
559 )
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum GuardKind {
567 Authorization,
569 Freshness,
571 Reservation,
573 Policy,
575}
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(rename_all = "snake_case")]
580pub enum OrderingPolicy {
581 StrictSequence,
583 Commutative,
585 LatestStateReconcile,
587 RejectUnordered,
589}
590
591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct TriggerEnvelope {
594 pub event_id: String,
596 pub event_type: String,
598 pub source: String,
600 pub schema_version: String,
602 pub tenant_id: TenantId,
604 pub subject_id: SubjectId,
606 pub aggregate_id: String,
608 pub source_sequence: Option<i64>,
610 pub observed_version: Option<String>,
612 pub occurred_at: DateTime<Utc>,
614 pub received_at: DateTime<Utc>,
616 pub watermark: Option<DateTime<Utc>>,
618 pub correlation_key: String,
620 pub dedup_key: String,
622 pub cursor: Option<String>,
624 pub payload: Value,
626 #[serde(default)]
628 pub trace_context: Value,
629}
630
631impl TriggerEnvelope {
632 pub fn validate(&self) -> Result<(), ContractError> {
634 for (name, value) in [
635 ("event_id", self.event_id.as_str()),
636 ("event_type", self.event_type.as_str()),
637 ("source", self.source.as_str()),
638 ("schema_version", self.schema_version.as_str()),
639 ("tenant_id", self.tenant_id.as_str()),
640 ("aggregate_id", self.aggregate_id.as_str()),
641 ("correlation_key", self.correlation_key.as_str()),
642 ("dedup_key", self.dedup_key.as_str()),
643 ] {
644 required(name, value)?;
645 }
646 if self
647 .cursor
648 .as_ref()
649 .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 4096)
650 {
651 return Err(ContractError::Invalid(
652 "source cursor requires 1..4096 bytes".into(),
653 ));
654 }
655 if self.occurred_at > self.received_at + chrono::Duration::minutes(5) {
656 return Err(ContractError::Invalid(
657 "occurred_at is implausibly ahead of received_at".into(),
658 ));
659 }
660 Ok(())
661 }
662}
663
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666pub struct SourceAdapterState {
667 pub source: af_context::WorkflowSourceId,
669 pub cursor: String,
671 pub source_event_id: Option<String>,
673}
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
676pub struct SourceIngestionReceipt {
677 pub event_id: String,
679 pub duplicate: bool,
681 pub deliveries: u64,
683 pub committed_cursor: Option<String>,
685}
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum CompletionPolicy {
691 ExplicitStop,
693 FirstTrigger,
695 FirstMatch,
697 FirstActionTerminal,
699 FirstSuccess,
701 AfterMatchedEvaluations(u64),
703 AfterSuccessfulRuns(u64),
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "snake_case")]
710pub enum ExpiryPolicy {
711 Drain,
713 Cancel,
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719#[serde(rename_all = "snake_case")]
720pub enum ScheduleCadence {
721 Cron {
723 expression: String,
725 },
726 FixedRate {
728 milliseconds: u64,
730 },
731 FixedDelay {
733 milliseconds: u64,
735 },
736}
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(rename_all = "snake_case")]
741pub enum CatchUpPolicy {
742 Skip,
744 CatchUpOnce,
746 CatchUpAll {
748 limit: u32,
750 },
751}
752
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct SchedulePolicy {
756 pub cadence: ScheduleCadence,
758 pub timezone: String,
760 pub catch_up: CatchUpPolicy,
762}
763
764impl SchedulePolicy {
765 pub fn validate(&self) -> Result<(), ContractError> {
767 self.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
768 ContractError::Invalid(format!("unknown IANA timezone '{}'", self.timezone))
769 })?;
770 match &self.cadence {
771 ScheduleCadence::Cron { expression } => {
772 expression.parse::<cron::Schedule>().map_err(|error| {
773 ContractError::Invalid(format!("invalid cron '{expression}': {error}"))
774 })?;
775 }
776 ScheduleCadence::FixedRate { milliseconds }
777 | ScheduleCadence::FixedDelay { milliseconds }
778 if *milliseconds == 0 =>
779 {
780 return Err(ContractError::Invalid(
781 "schedule interval must be positive".into(),
782 ));
783 }
784 _ => {}
785 }
786 if matches!(self.catch_up, CatchUpPolicy::CatchUpAll { limit: 0 }) {
787 return Err(ContractError::Invalid(
788 "catch_up_all limit must be positive".into(),
789 ));
790 }
791 Ok(())
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797pub struct LifecyclePolicy {
798 pub starts_at: Option<DateTime<Utc>>,
800 pub expires_at: Option<DateTime<Utc>>,
802 pub completion: CompletionPolicy,
804 pub event_idle_timeout_ms: Option<u64>,
806 pub progress_timeout_ms: Option<u64>,
808 pub on_expiry: ExpiryPolicy,
810 pub drain_deadline: Option<DateTime<Utc>>,
812}
813
814impl LifecyclePolicy {
815 pub fn run_once() -> Self {
817 Self {
818 starts_at: None,
819 expires_at: None,
820 completion: CompletionPolicy::FirstSuccess,
821 event_idle_timeout_ms: None,
822 progress_timeout_ms: None,
823 on_expiry: ExpiryPolicy::Drain,
824 drain_deadline: None,
825 }
826 }
827
828 pub fn validate(&self) -> Result<(), ContractError> {
830 if self
831 .starts_at
832 .zip(self.expires_at)
833 .is_some_and(|(a, b)| a >= b)
834 {
835 return Err(ContractError::Invalid(
836 "lifecycle starts_at must be before expires_at".into(),
837 ));
838 }
839 if self.drain_deadline.is_some() && self.on_expiry != ExpiryPolicy::Drain {
840 return Err(ContractError::Invalid(
841 "drain_deadline requires on_expiry=drain".into(),
842 ));
843 }
844 if self
845 .expires_at
846 .zip(self.drain_deadline)
847 .is_some_and(|(expires, drain)| drain <= expires)
848 {
849 return Err(ContractError::Invalid(
850 "lifecycle drain_deadline must be after expires_at".into(),
851 ));
852 }
853 if self.event_idle_timeout_ms == Some(0) || self.progress_timeout_ms == Some(0) {
854 return Err(ContractError::Invalid(
855 "lifecycle timeouts must be positive".into(),
856 ));
857 }
858 if matches!(
859 self.completion,
860 CompletionPolicy::AfterMatchedEvaluations(0) | CompletionPolicy::AfterSuccessfulRuns(0)
861 ) {
862 return Err(ContractError::Invalid(
863 "lifecycle completion count must be positive".into(),
864 ));
865 }
866 Ok(())
867 }
868}
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "snake_case")]
873pub enum StepOutcomeKind {
874 Succeeded,
876 Skipped,
878 Waiting,
880 Failed,
882 Cancelled,
884}
885
886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888pub struct StepOutcome {
889 pub kind: StepOutcomeKind,
891 pub reason_code: Option<String>,
893 #[serde(default)]
895 pub wake_condition: Value,
896}
897
898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "snake_case")]
901pub enum ActionState {
902 Prepared,
904 AwaitingConfirmation,
906 Authorized,
908 DispatchCommitted,
910 Executing,
912 Succeeded,
914 Rejected,
916 Retryable,
918 Unknown,
920 Reconciled,
922}
923
924impl ActionState {
925 pub fn can_transition_to(self, next: Self) -> bool {
927 use ActionState::*;
928 matches!(
929 (self, next),
930 (Prepared, AwaitingConfirmation | Authorized | Rejected)
931 | (AwaitingConfirmation, Authorized | Rejected)
932 | (Authorized, DispatchCommitted | Rejected)
933 | (
934 DispatchCommitted,
935 Executing | Succeeded | Rejected | Retryable | Unknown
936 )
937 | (Executing, Succeeded | Rejected | Retryable | Unknown)
938 | (Retryable, DispatchCommitted | Reconciled)
939 | (Unknown, Reconciled)
940 | (Succeeded | Rejected, Reconciled)
941 )
942 }
943
944 pub fn dispatch_committed(self) -> bool {
946 matches!(
947 self,
948 Self::DispatchCommitted
949 | Self::Executing
950 | Self::Succeeded
951 | Self::Rejected
952 | Self::Retryable
953 | Self::Unknown
954 | Self::Reconciled
955 )
956 }
957}
958
959#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
961pub struct ControlEpochs {
962 pub tenant: i64,
964 pub resource: i64,
966 pub instance: i64,
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(rename_all = "snake_case")]
973pub enum ControlScope {
974 Tenant,
976 Resource,
978 Instance,
980}
981
982#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
984#[serde(rename_all = "snake_case")]
985pub enum ControlMode {
986 Running,
988 Paused,
990 Stopped,
992 EmergencyStopped,
994}
995
996#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
998pub struct ControlCommand {
999 pub scope: ControlScope,
1001 pub scope_id: String,
1003 pub mode: ControlMode,
1005 pub operator_subject_id: SubjectId,
1007 pub reason: String,
1009 pub override_expires_at: Option<DateTime<Utc>>,
1011}
1012
1013impl ControlCommand {
1014 pub fn validate(&self) -> Result<(), ContractError> {
1016 required("control scope_id", &self.scope_id)?;
1017 required("control operator_subject_id", &self.operator_subject_id)?;
1018 required("control reason", &self.reason)?;
1019 Ok(())
1020 }
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025pub struct ResourceReservationRef {
1026 pub reservation_id: String,
1028 pub fencing_token: i64,
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1034pub struct ActionIntent {
1035 pub id: String,
1037 pub tenant_id: TenantId,
1039 pub instance_id: InstanceId,
1041 pub run_id: RunId,
1043 pub capability: CapabilityPin,
1045 pub idempotency_key: String,
1047 pub state: ActionState,
1049 pub input: Value,
1051 pub effect: Effect,
1053 pub retry_class: IdempotencyMode,
1055 pub control_epochs: ControlEpochs,
1057 pub resource_scope_id: String,
1059 pub lease_epoch: i64,
1061 pub action_epoch: i64,
1063 pub deadline: Option<DateTime<Utc>>,
1065 pub reservation: Option<ResourceReservationRef>,
1067 pub created_at: DateTime<Utc>,
1069}
1070
1071impl ActionIntent {
1072 pub fn validate(&self) -> Result<(), ContractError> {
1074 required("action id", &self.id)?;
1075 required("action idempotency_key", &self.idempotency_key)?;
1076 if self.effect == Effect::Funds && self.reservation.is_none() {
1077 return Err(ContractError::Invalid(
1078 "funds action requires a resource reservation".into(),
1079 ));
1080 }
1081 if self.effect == Effect::Funds && self.resource_scope_id.trim().is_empty() {
1082 return Err(ContractError::Invalid(
1083 "funds action requires a resource scope".into(),
1084 ));
1085 }
1086 if self.effect == Effect::Funds && self.retry_class == IdempotencyMode::None {
1087 return Err(ContractError::Invalid(
1088 "funds action requires an explicit retry class".into(),
1089 ));
1090 }
1091 if self
1092 .deadline
1093 .is_some_and(|deadline| deadline <= self.created_at)
1094 {
1095 return Err(ContractError::Invalid(
1096 "action deadline must be after creation".into(),
1097 ));
1098 }
1099 Ok(())
1100 }
1101
1102 pub fn validate_prepared(&self) -> Result<(), ContractError> {
1105 self.validate()?;
1106 if self.state != ActionState::Prepared {
1107 return Err(ContractError::Invalid(
1108 "new action intent must start in prepared state".into(),
1109 ));
1110 }
1111 Ok(())
1112 }
1113}
1114
1115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1117pub struct ActionReceipt {
1118 pub id: String,
1120 pub action_intent_id: String,
1122 pub provider_version: String,
1124 pub received_at: DateTime<Utc>,
1126 pub outcome: ActionState,
1128 pub payload: Value,
1130 pub raw_receipt_digest: String,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1136pub struct ActionObservation {
1137 pub id: String,
1139 pub action_intent_id: String,
1141 pub provider_version: String,
1143 pub observed_at: DateTime<Utc>,
1145 pub state: String,
1147 pub resource_ref: Option<Value>,
1149 pub raw_receipt_digest: String,
1151 pub terminal: bool,
1153 #[serde(default)]
1156 pub retry_authorized: bool,
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1161pub struct SourceGap {
1162 pub tenant_id: TenantId,
1164 pub source: String,
1166 pub aggregate_id: String,
1168 pub missing_from: i64,
1170 pub missing_to: i64,
1172 pub deadline: DateTime<Utc>,
1174 pub status: String,
1176}
1177
1178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1180#[serde(rename_all = "snake_case")]
1181pub enum ReservationScope {
1182 Tenant,
1184 Resource,
1186}
1187
1188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1190#[serde(rename_all = "snake_case")]
1191pub enum ReservationState {
1192 Reserved,
1194 Consumed,
1196 Released,
1198 Expired,
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1204pub struct ResourceReservation {
1205 pub id: String,
1207 pub tenant_id: TenantId,
1209 pub scope: ReservationScope,
1211 pub scope_id: String,
1213 pub resource_kind: String,
1215 pub amount: String,
1217 pub policy_version: String,
1219 pub expires_at: DateTime<Utc>,
1221 pub fencing_token: i64,
1223 pub provider_reservation_id: Option<String>,
1225 pub state: ReservationState,
1227}
1228
1229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1231pub struct ObservedValue<T> {
1232 pub value: T,
1234 pub source: String,
1236 pub observed_at: DateTime<Utc>,
1238 pub received_at: DateTime<Utc>,
1240 pub source_version: String,
1242 pub quality: String,
1244 pub digest: String,
1246}
1247
1248impl<T> ObservedValue<T> {
1249 pub fn is_accepted(
1251 &self,
1252 now: DateTime<Utc>,
1253 max_age: chrono::Duration,
1254 accepted_quality: &BTreeSet<String>,
1255 ) -> bool {
1256 self.observed_at <= now
1257 && now - self.observed_at <= max_age
1258 && accepted_quality.contains(&self.quality)
1259 }
1260}
1261
1262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264pub struct DecisionSnapshot {
1265 pub input_digests: Vec<String>,
1267 pub instance_config_version: String,
1269 pub policy_version: String,
1271 pub capability_versions: Vec<CapabilityPin>,
1273 pub execution_profile_revision_id: String,
1275 #[serde(default)]
1277 pub context_snapshot: Value,
1278 #[serde(default)]
1280 pub policy_snapshot: Value,
1281 #[serde(default)]
1283 pub agent_provenance: Value,
1284}
1285
1286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(rename_all = "snake_case")]
1289pub enum ParameterProvenance {
1290 UserSupplied,
1292 ProductDefault,
1294 AgentInferred,
1296 Derived,
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1302pub struct ParameterValue {
1303 pub value: Value,
1305 pub provenance: ParameterProvenance,
1307}
1308
1309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1311pub struct ParameterSpec {
1312 pub name: String,
1314 pub required: bool,
1316 pub required_explicit_for_live: bool,
1318}
1319
1320pub fn validate_parameters(
1322 mode: ExecutionMode,
1323 specs: &[ParameterSpec],
1324 values: &BTreeMap<String, ParameterValue>,
1325) -> Result<(), MissingRequirements> {
1326 let missing = specs
1327 .iter()
1328 .filter(|spec| match values.get(&spec.name) {
1329 None => {
1330 spec.required || (mode == ExecutionMode::Live && spec.required_explicit_for_live)
1331 }
1332 Some(value) => {
1333 mode == ExecutionMode::Live
1334 && spec.required_explicit_for_live
1335 && value.provenance != ParameterProvenance::UserSupplied
1336 }
1337 })
1338 .map(|spec| spec.name.clone())
1339 .collect::<Vec<_>>();
1340 if missing.is_empty() {
1341 Ok(())
1342 } else {
1343 Err(MissingRequirements {
1344 parameters: missing,
1345 })
1346 }
1347}
1348
1349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1351#[error("missing explicit workflow requirements: {parameters:?}")]
1352pub struct MissingRequirements {
1353 pub parameters: Vec<String>,
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1359pub struct RootOperationBudget {
1360 pub root_operation_id: String,
1362 pub max_depth: u32,
1364 pub descendant_limit: u32,
1366 pub run_limit: u32,
1368 pub token_budget: u64,
1370 pub cost_budget_micros: u64,
1372 pub action_budget: u32,
1374 pub deadline: DateTime<Utc>,
1376 #[serde(default)]
1378 pub permission_ceiling: BTreeSet<String>,
1379}
1380
1381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1383pub struct DecisionArtifact {
1384 pub id: String,
1386 pub root_operation_id: String,
1388 pub content_digest: String,
1390 pub model: String,
1392 pub prompt_digest: String,
1394 pub output: Value,
1396 pub created_at: DateTime<Utc>,
1398}
1399
1400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1402pub struct DiagnosticRecord {
1403 pub code: String,
1405 pub message: String,
1407 pub at: DateTime<Utc>,
1409 #[serde(default)]
1411 pub detail: Value,
1412}
1413
1414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1416pub struct ExecutionProjection {
1417 pub schema_version: String,
1419 pub source_sequence: i64,
1421 pub current_steps: Vec<String>,
1423 pub completed_steps: Vec<String>,
1425 pub waiting_on: Option<Value>,
1427 pub last_decision: Option<Value>,
1429 pub next_possible_steps: Vec<String>,
1431 pub next_trigger_at: Option<DateTime<Utc>>,
1433 pub planned_actions: Vec<String>,
1435 pub latest_diagnostic: Option<DiagnosticRecord>,
1437 pub progress: Value,
1439 pub expires_at: Option<DateTime<Utc>>,
1441}
1442
1443#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1445pub enum ContractError {
1446 #[error("invalid workflow contract: {0}")]
1448 Invalid(String),
1449}
1450
1451fn required(name: &str, value: &str) -> Result<(), ContractError> {
1452 if value.trim().is_empty() {
1453 Err(ContractError::Invalid(format!("{name} is required")))
1454 } else {
1455 Ok(())
1456 }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461 use super::*;
1462
1463 fn empty_spec() -> crate::Spec {
1464 crate::Spec {
1465 spec_id: "test".into(),
1466 version: "1".into(),
1467 description: String::new(),
1468 aliases: vec![],
1469 instance_config_schema: None,
1470 display: BTreeMap::new(),
1471 branches: vec![],
1472 }
1473 }
1474
1475 fn funds_manifest() -> CapabilityManifest {
1476 CapabilityManifest::action(
1477 "action.example",
1478 "1",
1479 "sha256:x",
1480 Effect::Funds,
1481 IdempotencyMode::ReconcileBeforeRetry,
1482 true,
1483 )
1484 }
1485
1486 #[test]
1487 fn funds_capability_requires_reconciliation_and_all_guards() {
1488 assert!(funds_manifest().validate().is_ok());
1489 let mut invalid = funds_manifest();
1490 invalid.required_guards.remove(&GuardKind::Freshness);
1491 assert!(invalid.validate().is_err());
1492 invalid.required_guards.insert(GuardKind::Freshness);
1493 invalid.supports_reconciliation = false;
1494 assert!(invalid.validate().is_err());
1495 }
1496
1497 #[test]
1498 fn action_state_never_skips_dispatch_commit() {
1499 assert!(ActionState::Authorized.can_transition_to(ActionState::DispatchCommitted));
1500 assert!(!ActionState::Authorized.can_transition_to(ActionState::Succeeded));
1501 assert!(ActionState::Unknown.can_transition_to(ActionState::Reconciled));
1502 }
1503
1504 #[test]
1505 fn live_parameters_cannot_be_silently_inferred() {
1506 let specs = [ParameterSpec {
1507 name: "account".into(),
1508 required: true,
1509 required_explicit_for_live: true,
1510 }];
1511 let inferred = BTreeMap::from([(
1512 "account".into(),
1513 ParameterValue {
1514 value: Value::String("a".into()),
1515 provenance: ParameterProvenance::AgentInferred,
1516 },
1517 )]);
1518 assert_eq!(
1519 validate_parameters(ExecutionMode::Live, &specs, &inferred)
1520 .unwrap_err()
1521 .parameters,
1522 ["account"]
1523 );
1524 assert!(validate_parameters(ExecutionMode::Paper, &specs, &inferred).is_ok());
1525 }
1526
1527 #[test]
1528 fn freshness_is_fail_closed() {
1529 let now = Utc::now();
1530 let observed = ObservedValue {
1531 value: 1,
1532 source: "source".into(),
1533 observed_at: now - chrono::Duration::seconds(2),
1534 received_at: now,
1535 source_version: "1".into(),
1536 quality: "good".into(),
1537 digest: "d".into(),
1538 };
1539 assert!(observed.is_accepted(
1540 now,
1541 chrono::Duration::seconds(3),
1542 &BTreeSet::from(["good".into()])
1543 ));
1544 assert!(!observed.is_accepted(
1545 now,
1546 chrono::Duration::seconds(1),
1547 &BTreeSet::from(["good".into()])
1548 ));
1549 }
1550
1551 #[test]
1552 fn revision_and_manifest_contracts_fail_closed() {
1553 let mut revision = WorkflowRevision {
1554 definition_id: "definition".into(),
1555 revision: 1,
1556 content_digest: "digest".into(),
1557 kernel_abi_version: "1".into(),
1558 dependency_set_digest: "dependencies".into(),
1559 expression_versions: BTreeMap::new(),
1560 capabilities: vec![CapabilityPin {
1561 id: "action.example".into(),
1562 contract_version: "1".into(),
1563 content_digest: "capability-digest".into(),
1564 }],
1565 template_provenance: Value::Null,
1566 spec: empty_spec(),
1567 };
1568 assert!(revision.validate().is_ok());
1569 revision.capabilities.push(CapabilityPin {
1570 id: "action.example".into(),
1571 contract_version: "2".into(),
1572 content_digest: "capability-digest-v2".into(),
1573 });
1574 assert!(revision.validate().is_err());
1575 revision.capabilities.pop();
1576 revision.kernel_abi_version.clear();
1577 assert!(revision.validate().is_err());
1578
1579 let external = CapabilityManifest::action(
1580 "action.notify",
1581 "1",
1582 "digest",
1583 Effect::ExternalWrite,
1584 IdempotencyMode::NeverAutomaticRetry,
1585 false,
1586 );
1587 assert_eq!(
1588 external.required_guards,
1589 BTreeSet::from([GuardKind::Authorization])
1590 );
1591 assert!(external.validate().is_ok());
1592 assert_eq!(external.config_schema, serde_json::json!({"type":"object"}));
1593 let mut invalid_schema = external.clone();
1594 invalid_schema.config_schema = serde_json::json!({"type":7});
1595 assert!(invalid_schema.validate().is_err());
1596 let mut invalid = external;
1597 invalid.retry.max_attempts = 0;
1598 assert!(invalid.validate().is_err());
1599
1600 let mut wrong_kind = funds_manifest();
1601 wrong_kind.kind = CapabilityKind::Expression;
1602 assert!(wrong_kind.validate().is_err());
1603 wrong_kind.kind = CapabilityKind::Action;
1604 wrong_kind.idempotency_mode = IdempotencyMode::Native;
1605 wrong_kind.supports_reconciliation = false;
1606 assert!(wrong_kind.validate().is_ok());
1607 wrong_kind.lifecycle = CapabilityLifecycle::Disabled;
1608 assert!(!wrong_kind.can_start_new_work());
1609 }
1610
1611 #[test]
1612 fn trigger_schedule_lifecycle_and_control_validate_boundaries() {
1613 let now = Utc::now();
1614 let mut trigger = TriggerEnvelope {
1615 event_id: "event".into(),
1616 event_type: "example".into(),
1617 source: "source".into(),
1618 schema_version: "1".into(),
1619 tenant_id: "tenant".parse().unwrap(),
1620 subject_id: "subject".parse().unwrap(),
1621 aggregate_id: "aggregate".into(),
1622 source_sequence: Some(1),
1623 observed_version: None,
1624 occurred_at: now,
1625 received_at: now,
1626 watermark: None,
1627 correlation_key: "key".into(),
1628 dedup_key: "dedup".into(),
1629 cursor: None,
1630 payload: Value::Null,
1631 trace_context: Value::Null,
1632 };
1633 assert!(trigger.validate().is_ok());
1634 trigger.occurred_at = now + chrono::Duration::minutes(6);
1635 assert!(trigger.validate().is_err());
1636
1637 let valid_schedule = SchedulePolicy {
1638 cadence: ScheduleCadence::Cron {
1639 expression: "0 0 * * * *".into(),
1640 },
1641 timezone: "Asia/Shanghai".into(),
1642 catch_up: CatchUpPolicy::CatchUpOnce,
1643 };
1644 assert!(valid_schedule.validate().is_ok());
1645 for invalid in [
1646 SchedulePolicy {
1647 timezone: "Nowhere/Invalid".into(),
1648 ..valid_schedule.clone()
1649 },
1650 SchedulePolicy {
1651 cadence: ScheduleCadence::FixedRate { milliseconds: 0 },
1652 ..valid_schedule.clone()
1653 },
1654 SchedulePolicy {
1655 catch_up: CatchUpPolicy::CatchUpAll { limit: 0 },
1656 ..valid_schedule
1657 },
1658 ] {
1659 assert!(invalid.validate().is_err());
1660 }
1661
1662 let mut lifecycle = LifecyclePolicy::run_once();
1663 assert!(lifecycle.validate().is_ok());
1664 lifecycle.starts_at = Some(now);
1665 lifecycle.expires_at = Some(now);
1666 assert!(lifecycle.validate().is_err());
1667 lifecycle.starts_at = None;
1668 lifecycle.expires_at = None;
1669 lifecycle.on_expiry = ExpiryPolicy::Cancel;
1670 lifecycle.drain_deadline = Some(now);
1671 assert!(lifecycle.validate().is_err());
1672
1673 let mut command = ControlCommand {
1674 scope: ControlScope::Instance,
1675 scope_id: "instance".into(),
1676 mode: ControlMode::Paused,
1677 operator_subject_id: "operator".parse().unwrap(),
1678 reason: "maintenance".into(),
1679 override_expires_at: None,
1680 };
1681 assert!(command.validate().is_ok());
1682 command.reason.clear();
1683 assert!(command.validate().is_err());
1684 }
1685
1686 #[test]
1687 fn funds_intent_requires_reservation_scope_and_retry_class() {
1688 let now = Utc::now();
1689 let mut intent = ActionIntent {
1690 id: "intent".into(),
1691 tenant_id: "tenant".parse().unwrap(),
1692 instance_id: "instance".parse().unwrap(),
1693 run_id: "run".parse().unwrap(),
1694 capability: CapabilityPin {
1695 id: "action.example".into(),
1696 contract_version: "1".into(),
1697 content_digest: "digest".into(),
1698 },
1699 idempotency_key: "idempotency".into(),
1700 state: ActionState::Prepared,
1701 input: Value::Null,
1702 effect: Effect::Funds,
1703 retry_class: IdempotencyMode::ReconcileBeforeRetry,
1704 control_epochs: ControlEpochs::default(),
1705 resource_scope_id: "resource".into(),
1706 lease_epoch: 1,
1707 action_epoch: 1,
1708 deadline: None,
1709 reservation: Some(ResourceReservationRef {
1710 reservation_id: "reservation".into(),
1711 fencing_token: 1,
1712 }),
1713 created_at: now,
1714 };
1715 assert!(intent.validate().is_ok());
1716 assert!(intent.validate_prepared().is_ok());
1717 intent.reservation = None;
1718 assert!(intent.validate().is_err());
1719 intent.reservation = Some(ResourceReservationRef {
1720 reservation_id: "reservation".into(),
1721 fencing_token: 1,
1722 });
1723 intent.resource_scope_id.clear();
1724 assert!(intent.validate().is_err());
1725 intent.resource_scope_id = "resource".into();
1726 intent.retry_class = IdempotencyMode::None;
1727 assert!(intent.validate().is_err());
1728 intent.retry_class = IdempotencyMode::ReconcileBeforeRetry;
1729 intent.state = ActionState::Authorized;
1730 assert!(intent.validate_prepared().is_err());
1731 assert!(!ActionState::Prepared.dispatch_committed());
1732 assert!(ActionState::Unknown.dispatch_committed());
1733 }
1734
1735 fn trigger_binding() -> TriggerBinding {
1736 TriggerBinding {
1737 id: "binding".into(),
1738 revision: 1,
1739 source: "source".into(),
1740 event_type: "event".into(),
1741 instance_id: "instance".parse().unwrap(),
1742 branch_id: None,
1743 predicate: serde_json::json!({}),
1744 ordering: OrderingPolicy::Commutative,
1745 starts_at: None,
1746 expires_at: None,
1747 gap_wait_ms: default_gap_wait_ms(),
1748 gap_limit: default_gap_limit(),
1749 }
1750 }
1751
1752 #[test]
1753 fn trigger_binding_rejects_blank_ids_bad_gaps_and_inverted_windows() {
1754 assert!(trigger_binding().validate().is_ok());
1755 let blank = TriggerBinding {
1756 source: " ".into(),
1757 ..trigger_binding()
1758 };
1759 assert!(blank.validate().is_err());
1760 for gap_wait_ms in [0, 3_600_001] {
1761 let binding = TriggerBinding {
1762 gap_wait_ms,
1763 ..trigger_binding()
1764 };
1765 assert!(binding.validate().is_err(), "gap_wait_ms {gap_wait_ms}");
1766 }
1767 for gap_limit in [0, 10_001] {
1768 let binding = TriggerBinding {
1769 gap_limit,
1770 ..trigger_binding()
1771 };
1772 assert!(binding.validate().is_err(), "gap_limit {gap_limit}");
1773 }
1774 let array_predicate = TriggerBinding {
1775 predicate: serde_json::json!([1]),
1776 ..trigger_binding()
1777 };
1778 assert!(array_predicate.validate().is_err());
1779 let now = Utc::now();
1780 let inverted = TriggerBinding {
1781 starts_at: Some(now),
1782 expires_at: Some(now),
1783 ..trigger_binding()
1784 };
1785 assert!(inverted.validate().is_err());
1786 let ordered = TriggerBinding {
1787 starts_at: Some(now),
1788 expires_at: Some(now + chrono::Duration::seconds(1)),
1789 ..trigger_binding()
1790 };
1791 assert!(ordered.validate().is_ok());
1792 let defaults: TriggerBinding = serde_json::from_value(serde_json::json!({
1793 "id": "binding", "revision": 1, "source": "s", "event_type": "e",
1794 "instance_id": "i", "predicate": {}, "ordering": "commutative",
1795 "starts_at": null, "expires_at": null
1796 }))
1797 .unwrap();
1798 assert_eq!(defaults.gap_wait_ms, 30_000);
1799 assert_eq!(defaults.gap_limit, 100);
1800 }
1801
1802 #[test]
1803 fn execution_profile_revision_requires_every_provider_name() {
1804 let profile = ExecutionProfileRevision {
1805 id: "profile".into(),
1806 revision: 1,
1807 content_digest: "sha256:profile".into(),
1808 mode: ExecutionMode::Paper,
1809 durability_grade: DurabilityGrade::Standard,
1810 trigger_provider: "triggers".into(),
1811 data_provider: "data".into(),
1812 clock_model: "database".into(),
1813 action_provider: "actions".into(),
1814 models: BTreeMap::new(),
1815 environment: Value::Null,
1816 policy_bundle: Value::Null,
1817 connection_bindings: BTreeMap::new(),
1818 };
1819 assert!(profile.validate().is_ok());
1820 let blank_provider = ExecutionProfileRevision {
1821 action_provider: String::new(),
1822 ..profile.clone()
1823 };
1824 assert!(blank_provider.validate().is_err());
1825 let blank_digest = ExecutionProfileRevision {
1826 content_digest: " ".into(),
1827 ..profile
1828 };
1829 assert!(blank_digest.validate().is_err());
1830 }
1831
1832 #[test]
1833 fn retry_backoff_grows_exponentially_with_bounded_jitter_and_cap() {
1834 let policy = RetryPolicy {
1835 max_attempts: 5,
1836 timeout_ms: 1_000,
1837 initial_backoff_ms: 100,
1838 max_backoff_ms: 1_000,
1839 };
1840 assert_eq!(policy.backoff_ms(1, 0), 100);
1841 assert_eq!(policy.backoff_ms(2, 0), 200);
1842 assert_eq!(policy.backoff_ms(3, 0), 400);
1843 assert_eq!(policy.backoff_ms(1, 24), 124);
1845 assert_eq!(policy.backoff_ms(1, 25), 100);
1846 assert_eq!(policy.backoff_ms(4, 249), 849);
1848 assert_eq!(policy.backoff_ms(5, 0), 1_000);
1850 assert_eq!(policy.backoff_ms(5, 249), 1_000);
1851 assert_eq!(policy.backoff_ms(40, u64::MAX), 1_000);
1852 assert_eq!(policy.backoff_ms(0, 0), 100);
1854 }
1855
1856 #[test]
1857 fn lifecycle_policy_rejects_inconsistent_windows_and_zero_counts() {
1858 let now = Utc::now();
1859 let later = now + chrono::Duration::hours(1);
1860 assert!(LifecyclePolicy::run_once().validate().is_ok());
1861 let inverted = LifecyclePolicy {
1862 starts_at: Some(later),
1863 expires_at: Some(now),
1864 ..LifecyclePolicy::run_once()
1865 };
1866 assert!(inverted.validate().is_err());
1867 let drain_without_policy = LifecyclePolicy {
1868 on_expiry: ExpiryPolicy::Cancel,
1869 drain_deadline: Some(later),
1870 ..LifecyclePolicy::run_once()
1871 };
1872 assert!(drain_without_policy.validate().is_err());
1873 let drain_before_expiry = LifecyclePolicy {
1874 expires_at: Some(later),
1875 drain_deadline: Some(now),
1876 ..LifecyclePolicy::run_once()
1877 };
1878 assert!(drain_before_expiry.validate().is_err());
1879 let zero_timeout = LifecyclePolicy {
1880 event_idle_timeout_ms: Some(0),
1881 ..LifecyclePolicy::run_once()
1882 };
1883 assert!(zero_timeout.validate().is_err());
1884 for completion in [
1885 CompletionPolicy::AfterMatchedEvaluations(0),
1886 CompletionPolicy::AfterSuccessfulRuns(0),
1887 ] {
1888 let zero_count = LifecyclePolicy {
1889 completion,
1890 ..LifecyclePolicy::run_once()
1891 };
1892 assert!(zero_count.validate().is_err());
1893 }
1894 let bounded = LifecyclePolicy {
1895 starts_at: Some(now),
1896 expires_at: Some(later),
1897 completion: CompletionPolicy::AfterSuccessfulRuns(2),
1898 event_idle_timeout_ms: Some(1_000),
1899 progress_timeout_ms: Some(1_000),
1900 on_expiry: ExpiryPolicy::Drain,
1901 drain_deadline: Some(later + chrono::Duration::minutes(1)),
1902 };
1903 assert!(bounded.validate().is_ok());
1904 }
1905}