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)]
417 pub taint_rules: Value,
418 #[serde(default)]
420 pub resource_cost: Value,
421 pub supports_simulation: bool,
423 pub supports_replay: bool,
425 pub supports_paper: bool,
427 pub supports_live: bool,
429 #[serde(default)]
431 pub clock_requirements: Value,
432 #[serde(default)]
434 pub data_requirements: Value,
435 pub supports_reconciliation: bool,
437 pub lifecycle: CapabilityLifecycle,
439}
440
441fn object_schema() -> Value {
442 serde_json::json!({"type":"object"})
443}
444
445impl CapabilityManifest {
446 pub fn action(
448 id: impl Into<String>,
449 contract_version: impl Into<String>,
450 content_digest: impl Into<String>,
451 effect: Effect,
452 idempotency_mode: IdempotencyMode,
453 supports_reconciliation: bool,
454 ) -> Self {
455 let required_guards = match effect {
456 Effect::Funds => BTreeSet::from([
457 GuardKind::Authorization,
458 GuardKind::Freshness,
459 GuardKind::Reservation,
460 ]),
461 Effect::ExternalWrite => BTreeSet::from([GuardKind::Authorization]),
462 _ => BTreeSet::new(),
463 };
464 Self {
465 id: id.into(),
466 contract_version: contract_version.into(),
467 content_digest: content_digest.into(),
468 kind: CapabilityKind::Action,
469 config_schema: object_schema(),
470 input_schema: serde_json::json!({"type": "object"}),
471 output_schema: serde_json::json!({"type": "object"}),
472 effect,
473 deterministic: false,
474 idempotency_mode,
475 retry: RetryPolicy {
476 max_attempts: 1,
477 timeout_ms: 30_000,
478 initial_backoff_ms: 100,
479 max_backoff_ms: 5_000,
480 },
481 permissions: BTreeSet::new(),
482 required_guards,
483 taint_rules: Value::Null,
484 resource_cost: Value::Null,
485 supports_simulation: true,
486 supports_replay: false,
487 supports_paper: true,
488 supports_live: true,
489 clock_requirements: Value::Null,
490 data_requirements: Value::Null,
491 supports_reconciliation,
492 lifecycle: CapabilityLifecycle::Active,
493 }
494 }
495
496 pub fn validate(&self) -> Result<(), ContractError> {
498 required("capability id", &self.id)?;
499 required("contract_version", &self.contract_version)?;
500 required("content_digest", &self.content_digest)?;
501 if !self.config_schema.is_object()
502 || jsonschema::validator_for(&self.config_schema).is_err()
503 {
504 return Err(ContractError::Invalid(
505 "capability config_schema must be a valid JSON Schema object".into(),
506 ));
507 }
508 if self.retry.max_attempts == 0
509 || self.retry.timeout_ms == 0
510 || self.retry.initial_backoff_ms == 0
511 || self.retry.max_backoff_ms < self.retry.initial_backoff_ms
512 {
513 return Err(ContractError::Invalid(
514 "retry attempts, timeout and backoff bounds are invalid".into(),
515 ));
516 }
517 if self.effect == Effect::Funds {
518 if self.kind != CapabilityKind::Action {
519 return Err(ContractError::Invalid(
520 "funds effect is only valid for action capabilities".into(),
521 ));
522 }
523 if !self.supports_reconciliation && self.idempotency_mode != IdempotencyMode::Native {
524 return Err(ContractError::Invalid(
525 "funds actions require native idempotency or reconciliation".into(),
526 ));
527 }
528 for guard in [
529 GuardKind::Authorization,
530 GuardKind::Freshness,
531 GuardKind::Reservation,
532 ] {
533 if !self.required_guards.contains(&guard) {
534 return Err(ContractError::Invalid(format!(
535 "funds action must require {guard:?} guard"
536 )));
537 }
538 }
539 }
540 Ok(())
541 }
542
543 pub fn can_start_new_work(&self) -> bool {
545 matches!(
546 self.lifecycle,
547 CapabilityLifecycle::Installed
548 | CapabilityLifecycle::Active
549 | CapabilityLifecycle::Deprecated
550 )
551 }
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
556#[serde(rename_all = "snake_case")]
557pub enum GuardKind {
558 Authorization,
560 Freshness,
562 Reservation,
564 Policy,
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(rename_all = "snake_case")]
571pub enum OrderingPolicy {
572 StrictSequence,
574 Commutative,
576 LatestStateReconcile,
578 RejectUnordered,
580}
581
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584pub struct TriggerEnvelope {
585 pub event_id: String,
587 pub event_type: String,
589 pub source: String,
591 pub schema_version: String,
593 pub tenant_id: TenantId,
595 pub subject_id: SubjectId,
597 pub aggregate_id: String,
599 pub source_sequence: Option<i64>,
601 pub observed_version: Option<String>,
603 pub occurred_at: DateTime<Utc>,
605 pub received_at: DateTime<Utc>,
607 pub watermark: Option<DateTime<Utc>>,
609 pub correlation_key: String,
611 pub dedup_key: String,
613 pub cursor: Option<String>,
615 pub payload: Value,
617 #[serde(default)]
619 pub trace_context: Value,
620}
621
622impl TriggerEnvelope {
623 pub fn validate(&self) -> Result<(), ContractError> {
625 for (name, value) in [
626 ("event_id", self.event_id.as_str()),
627 ("event_type", self.event_type.as_str()),
628 ("source", self.source.as_str()),
629 ("schema_version", self.schema_version.as_str()),
630 ("tenant_id", self.tenant_id.as_str()),
631 ("aggregate_id", self.aggregate_id.as_str()),
632 ("correlation_key", self.correlation_key.as_str()),
633 ("dedup_key", self.dedup_key.as_str()),
634 ] {
635 required(name, value)?;
636 }
637 if self
638 .cursor
639 .as_ref()
640 .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 4096)
641 {
642 return Err(ContractError::Invalid(
643 "source cursor requires 1..4096 bytes".into(),
644 ));
645 }
646 if self.occurred_at > self.received_at + chrono::Duration::minutes(5) {
647 return Err(ContractError::Invalid(
648 "occurred_at is implausibly ahead of received_at".into(),
649 ));
650 }
651 Ok(())
652 }
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct SourceAdapterState {
658 pub source: af_context::WorkflowSourceId,
660 pub cursor: String,
662 pub source_event_id: Option<String>,
664}
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667pub struct SourceIngestionReceipt {
668 pub event_id: String,
670 pub duplicate: bool,
672 pub deliveries: u64,
674 pub committed_cursor: Option<String>,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
680#[serde(rename_all = "snake_case")]
681pub enum CompletionPolicy {
682 ExplicitStop,
684 FirstTrigger,
686 FirstMatch,
688 FirstActionTerminal,
690 FirstSuccess,
692 AfterMatchedEvaluations(u64),
694 AfterSuccessfulRuns(u64),
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "snake_case")]
701pub enum ExpiryPolicy {
702 Drain,
704 Cancel,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(rename_all = "snake_case")]
711pub enum ScheduleCadence {
712 Cron {
714 expression: String,
716 },
717 FixedRate {
719 milliseconds: u64,
721 },
722 FixedDelay {
724 milliseconds: u64,
726 },
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum CatchUpPolicy {
733 Skip,
735 CatchUpOnce,
737 CatchUpAll {
739 limit: u32,
741 },
742}
743
744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
746pub struct SchedulePolicy {
747 pub cadence: ScheduleCadence,
749 pub timezone: String,
751 pub catch_up: CatchUpPolicy,
753}
754
755impl SchedulePolicy {
756 pub fn validate(&self) -> Result<(), ContractError> {
758 self.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
759 ContractError::Invalid(format!("unknown IANA timezone '{}'", self.timezone))
760 })?;
761 match &self.cadence {
762 ScheduleCadence::Cron { expression } => {
763 expression.parse::<cron::Schedule>().map_err(|error| {
764 ContractError::Invalid(format!("invalid cron '{expression}': {error}"))
765 })?;
766 }
767 ScheduleCadence::FixedRate { milliseconds }
768 | ScheduleCadence::FixedDelay { milliseconds }
769 if *milliseconds == 0 =>
770 {
771 return Err(ContractError::Invalid(
772 "schedule interval must be positive".into(),
773 ));
774 }
775 _ => {}
776 }
777 if matches!(self.catch_up, CatchUpPolicy::CatchUpAll { limit: 0 }) {
778 return Err(ContractError::Invalid(
779 "catch_up_all limit must be positive".into(),
780 ));
781 }
782 Ok(())
783 }
784}
785
786#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
788pub struct LifecyclePolicy {
789 pub starts_at: Option<DateTime<Utc>>,
791 pub expires_at: Option<DateTime<Utc>>,
793 pub completion: CompletionPolicy,
795 pub event_idle_timeout_ms: Option<u64>,
797 pub progress_timeout_ms: Option<u64>,
799 pub on_expiry: ExpiryPolicy,
801 pub drain_deadline: Option<DateTime<Utc>>,
803}
804
805impl LifecyclePolicy {
806 pub fn run_once() -> Self {
808 Self {
809 starts_at: None,
810 expires_at: None,
811 completion: CompletionPolicy::FirstSuccess,
812 event_idle_timeout_ms: None,
813 progress_timeout_ms: None,
814 on_expiry: ExpiryPolicy::Drain,
815 drain_deadline: None,
816 }
817 }
818
819 pub fn validate(&self) -> Result<(), ContractError> {
821 if self
822 .starts_at
823 .zip(self.expires_at)
824 .is_some_and(|(a, b)| a >= b)
825 {
826 return Err(ContractError::Invalid(
827 "lifecycle starts_at must be before expires_at".into(),
828 ));
829 }
830 if self.drain_deadline.is_some() && self.on_expiry != ExpiryPolicy::Drain {
831 return Err(ContractError::Invalid(
832 "drain_deadline requires on_expiry=drain".into(),
833 ));
834 }
835 if self
836 .expires_at
837 .zip(self.drain_deadline)
838 .is_some_and(|(expires, drain)| drain <= expires)
839 {
840 return Err(ContractError::Invalid(
841 "lifecycle drain_deadline must be after expires_at".into(),
842 ));
843 }
844 if self.event_idle_timeout_ms == Some(0) || self.progress_timeout_ms == Some(0) {
845 return Err(ContractError::Invalid(
846 "lifecycle timeouts must be positive".into(),
847 ));
848 }
849 if matches!(
850 self.completion,
851 CompletionPolicy::AfterMatchedEvaluations(0) | CompletionPolicy::AfterSuccessfulRuns(0)
852 ) {
853 return Err(ContractError::Invalid(
854 "lifecycle completion count must be positive".into(),
855 ));
856 }
857 Ok(())
858 }
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(rename_all = "snake_case")]
864pub enum StepOutcomeKind {
865 Succeeded,
867 Skipped,
869 Waiting,
871 Failed,
873 Cancelled,
875}
876
877#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
879pub struct StepOutcome {
880 pub kind: StepOutcomeKind,
882 pub reason_code: Option<String>,
884 #[serde(default)]
886 pub wake_condition: Value,
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(rename_all = "snake_case")]
892pub enum ActionState {
893 Prepared,
895 AwaitingConfirmation,
897 Authorized,
899 DispatchCommitted,
901 Executing,
903 Succeeded,
905 Rejected,
907 Retryable,
909 Unknown,
911 Reconciled,
913}
914
915impl ActionState {
916 pub fn can_transition_to(self, next: Self) -> bool {
918 use ActionState::*;
919 matches!(
920 (self, next),
921 (Prepared, AwaitingConfirmation | Authorized | Rejected)
922 | (AwaitingConfirmation, Authorized | Rejected)
923 | (Authorized, DispatchCommitted | Rejected)
924 | (
925 DispatchCommitted,
926 Executing | Succeeded | Rejected | Retryable | Unknown
927 )
928 | (Executing, Succeeded | Rejected | Retryable | Unknown)
929 | (Retryable, DispatchCommitted | Reconciled)
930 | (Unknown, Reconciled)
931 | (Succeeded | Rejected, Reconciled)
932 )
933 }
934
935 pub fn dispatch_committed(self) -> bool {
937 matches!(
938 self,
939 Self::DispatchCommitted
940 | Self::Executing
941 | Self::Succeeded
942 | Self::Rejected
943 | Self::Retryable
944 | Self::Unknown
945 | Self::Reconciled
946 )
947 }
948}
949
950#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
952pub struct ControlEpochs {
953 pub tenant: i64,
955 pub resource: i64,
957 pub instance: i64,
959}
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
963#[serde(rename_all = "snake_case")]
964pub enum ControlScope {
965 Tenant,
967 Resource,
969 Instance,
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
975#[serde(rename_all = "snake_case")]
976pub enum ControlMode {
977 Running,
979 Paused,
981 Stopped,
983 EmergencyStopped,
985}
986
987#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
989pub struct ControlCommand {
990 pub scope: ControlScope,
992 pub scope_id: String,
994 pub mode: ControlMode,
996 pub operator_subject_id: SubjectId,
998 pub reason: String,
1000 pub override_expires_at: Option<DateTime<Utc>>,
1002}
1003
1004impl ControlCommand {
1005 pub fn validate(&self) -> Result<(), ContractError> {
1007 required("control scope_id", &self.scope_id)?;
1008 required("control operator_subject_id", &self.operator_subject_id)?;
1009 required("control reason", &self.reason)?;
1010 Ok(())
1011 }
1012}
1013
1014#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1016pub struct ResourceReservationRef {
1017 pub reservation_id: String,
1019 pub fencing_token: i64,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1025pub struct ActionIntent {
1026 pub id: String,
1028 pub tenant_id: TenantId,
1030 pub instance_id: InstanceId,
1032 pub run_id: RunId,
1034 pub capability: CapabilityPin,
1036 pub idempotency_key: String,
1038 pub state: ActionState,
1040 pub input: Value,
1042 pub effect: Effect,
1044 pub retry_class: IdempotencyMode,
1046 pub control_epochs: ControlEpochs,
1048 pub resource_scope_id: String,
1050 pub lease_epoch: i64,
1052 pub action_epoch: i64,
1054 pub deadline: Option<DateTime<Utc>>,
1056 pub reservation: Option<ResourceReservationRef>,
1058 pub created_at: DateTime<Utc>,
1060}
1061
1062impl ActionIntent {
1063 pub fn validate(&self) -> Result<(), ContractError> {
1065 required("action id", &self.id)?;
1066 required("action idempotency_key", &self.idempotency_key)?;
1067 if self.effect == Effect::Funds && self.reservation.is_none() {
1068 return Err(ContractError::Invalid(
1069 "funds action requires a resource reservation".into(),
1070 ));
1071 }
1072 if self.effect == Effect::Funds && self.resource_scope_id.trim().is_empty() {
1073 return Err(ContractError::Invalid(
1074 "funds action requires a resource scope".into(),
1075 ));
1076 }
1077 if self.effect == Effect::Funds && self.retry_class == IdempotencyMode::None {
1078 return Err(ContractError::Invalid(
1079 "funds action requires an explicit retry class".into(),
1080 ));
1081 }
1082 if self
1083 .deadline
1084 .is_some_and(|deadline| deadline <= self.created_at)
1085 {
1086 return Err(ContractError::Invalid(
1087 "action deadline must be after creation".into(),
1088 ));
1089 }
1090 Ok(())
1091 }
1092
1093 pub fn validate_prepared(&self) -> Result<(), ContractError> {
1096 self.validate()?;
1097 if self.state != ActionState::Prepared {
1098 return Err(ContractError::Invalid(
1099 "new action intent must start in prepared state".into(),
1100 ));
1101 }
1102 Ok(())
1103 }
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1108pub struct ActionReceipt {
1109 pub id: String,
1111 pub action_intent_id: String,
1113 pub provider_version: String,
1115 pub received_at: DateTime<Utc>,
1117 pub outcome: ActionState,
1119 pub payload: Value,
1121 pub raw_receipt_digest: String,
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1127pub struct ActionObservation {
1128 pub id: String,
1130 pub action_intent_id: String,
1132 pub provider_version: String,
1134 pub observed_at: DateTime<Utc>,
1136 pub state: String,
1138 pub resource_ref: Option<Value>,
1140 pub raw_receipt_digest: String,
1142 pub terminal: bool,
1144 #[serde(default)]
1147 pub retry_authorized: bool,
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1152pub struct SourceGap {
1153 pub tenant_id: TenantId,
1155 pub source: String,
1157 pub aggregate_id: String,
1159 pub missing_from: i64,
1161 pub missing_to: i64,
1163 pub deadline: DateTime<Utc>,
1165 pub status: String,
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1171#[serde(rename_all = "snake_case")]
1172pub enum ReservationScope {
1173 Tenant,
1175 Resource,
1177}
1178
1179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(rename_all = "snake_case")]
1182pub enum ReservationState {
1183 Reserved,
1185 Consumed,
1187 Released,
1189 Expired,
1191}
1192
1193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1195pub struct ResourceReservation {
1196 pub id: String,
1198 pub tenant_id: TenantId,
1200 pub scope: ReservationScope,
1202 pub scope_id: String,
1204 pub resource_kind: String,
1206 pub amount: String,
1208 pub policy_version: String,
1210 pub expires_at: DateTime<Utc>,
1212 pub fencing_token: i64,
1214 pub provider_reservation_id: Option<String>,
1216 pub state: ReservationState,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1222pub struct ObservedValue<T> {
1223 pub value: T,
1225 pub source: String,
1227 pub observed_at: DateTime<Utc>,
1229 pub received_at: DateTime<Utc>,
1231 pub source_version: String,
1233 pub quality: String,
1235 pub digest: String,
1237}
1238
1239impl<T> ObservedValue<T> {
1240 pub fn is_accepted(
1242 &self,
1243 now: DateTime<Utc>,
1244 max_age: chrono::Duration,
1245 accepted_quality: &BTreeSet<String>,
1246 ) -> bool {
1247 self.observed_at <= now
1248 && now - self.observed_at <= max_age
1249 && accepted_quality.contains(&self.quality)
1250 }
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1255pub struct DecisionSnapshot {
1256 pub input_digests: Vec<String>,
1258 pub instance_config_version: String,
1260 pub policy_version: String,
1262 pub capability_versions: Vec<CapabilityPin>,
1264 pub execution_profile_revision_id: String,
1266 #[serde(default)]
1268 pub context_snapshot: Value,
1269 #[serde(default)]
1271 pub policy_snapshot: Value,
1272 #[serde(default)]
1274 pub agent_provenance: Value,
1275}
1276
1277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1279#[serde(rename_all = "snake_case")]
1280pub enum ParameterProvenance {
1281 UserSupplied,
1283 ProductDefault,
1285 AgentInferred,
1287 Derived,
1289}
1290
1291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1293pub struct ParameterValue {
1294 pub value: Value,
1296 pub provenance: ParameterProvenance,
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1302pub struct ParameterSpec {
1303 pub name: String,
1305 pub required: bool,
1307 pub required_explicit_for_live: bool,
1309}
1310
1311pub fn validate_parameters(
1313 mode: ExecutionMode,
1314 specs: &[ParameterSpec],
1315 values: &BTreeMap<String, ParameterValue>,
1316) -> Result<(), MissingRequirements> {
1317 let missing = specs
1318 .iter()
1319 .filter(|spec| match values.get(&spec.name) {
1320 None => {
1321 spec.required || (mode == ExecutionMode::Live && spec.required_explicit_for_live)
1322 }
1323 Some(value) => {
1324 mode == ExecutionMode::Live
1325 && spec.required_explicit_for_live
1326 && value.provenance != ParameterProvenance::UserSupplied
1327 }
1328 })
1329 .map(|spec| spec.name.clone())
1330 .collect::<Vec<_>>();
1331 if missing.is_empty() {
1332 Ok(())
1333 } else {
1334 Err(MissingRequirements {
1335 parameters: missing,
1336 })
1337 }
1338}
1339
1340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1342#[error("missing explicit workflow requirements: {parameters:?}")]
1343pub struct MissingRequirements {
1344 pub parameters: Vec<String>,
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1350pub struct RootOperationBudget {
1351 pub root_operation_id: String,
1353 pub max_depth: u32,
1355 pub descendant_limit: u32,
1357 pub run_limit: u32,
1359 pub token_budget: u64,
1361 pub cost_budget_micros: u64,
1363 pub action_budget: u32,
1365 pub deadline: DateTime<Utc>,
1367 #[serde(default)]
1369 pub permission_ceiling: BTreeSet<String>,
1370}
1371
1372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1374pub struct DecisionArtifact {
1375 pub id: String,
1377 pub root_operation_id: String,
1379 pub content_digest: String,
1381 pub model: String,
1383 pub prompt_digest: String,
1385 pub output: Value,
1387 pub created_at: DateTime<Utc>,
1389}
1390
1391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1393pub struct DiagnosticRecord {
1394 pub code: String,
1396 pub message: String,
1398 pub at: DateTime<Utc>,
1400 #[serde(default)]
1402 pub detail: Value,
1403}
1404
1405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1407pub struct ExecutionProjection {
1408 pub schema_version: String,
1410 pub source_sequence: i64,
1412 pub current_steps: Vec<String>,
1414 pub completed_steps: Vec<String>,
1416 pub waiting_on: Option<Value>,
1418 pub last_decision: Option<Value>,
1420 pub next_possible_steps: Vec<String>,
1422 pub next_trigger_at: Option<DateTime<Utc>>,
1424 pub planned_actions: Vec<String>,
1426 pub latest_diagnostic: Option<DiagnosticRecord>,
1428 pub progress: Value,
1430 pub expires_at: Option<DateTime<Utc>>,
1432}
1433
1434#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1436pub enum ContractError {
1437 #[error("invalid workflow contract: {0}")]
1439 Invalid(String),
1440}
1441
1442fn required(name: &str, value: &str) -> Result<(), ContractError> {
1443 if value.trim().is_empty() {
1444 Err(ContractError::Invalid(format!("{name} is required")))
1445 } else {
1446 Ok(())
1447 }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452 use super::*;
1453
1454 fn empty_spec() -> crate::Spec {
1455 crate::Spec {
1456 spec_id: "test".into(),
1457 version: "1".into(),
1458 description: String::new(),
1459 aliases: vec![],
1460 instance_config_schema: None,
1461 display: BTreeMap::new(),
1462 branches: vec![],
1463 }
1464 }
1465
1466 fn funds_manifest() -> CapabilityManifest {
1467 CapabilityManifest::action(
1468 "action.example",
1469 "1",
1470 "sha256:x",
1471 Effect::Funds,
1472 IdempotencyMode::ReconcileBeforeRetry,
1473 true,
1474 )
1475 }
1476
1477 #[test]
1478 fn funds_capability_requires_reconciliation_and_all_guards() {
1479 assert!(funds_manifest().validate().is_ok());
1480 let mut invalid = funds_manifest();
1481 invalid.required_guards.remove(&GuardKind::Freshness);
1482 assert!(invalid.validate().is_err());
1483 invalid.required_guards.insert(GuardKind::Freshness);
1484 invalid.supports_reconciliation = false;
1485 assert!(invalid.validate().is_err());
1486 }
1487
1488 #[test]
1489 fn action_state_never_skips_dispatch_commit() {
1490 assert!(ActionState::Authorized.can_transition_to(ActionState::DispatchCommitted));
1491 assert!(!ActionState::Authorized.can_transition_to(ActionState::Succeeded));
1492 assert!(ActionState::Unknown.can_transition_to(ActionState::Reconciled));
1493 }
1494
1495 #[test]
1496 fn live_parameters_cannot_be_silently_inferred() {
1497 let specs = [ParameterSpec {
1498 name: "account".into(),
1499 required: true,
1500 required_explicit_for_live: true,
1501 }];
1502 let inferred = BTreeMap::from([(
1503 "account".into(),
1504 ParameterValue {
1505 value: Value::String("a".into()),
1506 provenance: ParameterProvenance::AgentInferred,
1507 },
1508 )]);
1509 assert_eq!(
1510 validate_parameters(ExecutionMode::Live, &specs, &inferred)
1511 .unwrap_err()
1512 .parameters,
1513 ["account"]
1514 );
1515 assert!(validate_parameters(ExecutionMode::Paper, &specs, &inferred).is_ok());
1516 }
1517
1518 #[test]
1519 fn freshness_is_fail_closed() {
1520 let now = Utc::now();
1521 let observed = ObservedValue {
1522 value: 1,
1523 source: "source".into(),
1524 observed_at: now - chrono::Duration::seconds(2),
1525 received_at: now,
1526 source_version: "1".into(),
1527 quality: "good".into(),
1528 digest: "d".into(),
1529 };
1530 assert!(observed.is_accepted(
1531 now,
1532 chrono::Duration::seconds(3),
1533 &BTreeSet::from(["good".into()])
1534 ));
1535 assert!(!observed.is_accepted(
1536 now,
1537 chrono::Duration::seconds(1),
1538 &BTreeSet::from(["good".into()])
1539 ));
1540 }
1541
1542 #[test]
1543 fn revision_and_manifest_contracts_fail_closed() {
1544 let mut revision = WorkflowRevision {
1545 definition_id: "definition".into(),
1546 revision: 1,
1547 content_digest: "digest".into(),
1548 kernel_abi_version: "1".into(),
1549 dependency_set_digest: "dependencies".into(),
1550 expression_versions: BTreeMap::new(),
1551 capabilities: vec![CapabilityPin {
1552 id: "action.example".into(),
1553 contract_version: "1".into(),
1554 content_digest: "capability-digest".into(),
1555 }],
1556 template_provenance: Value::Null,
1557 spec: empty_spec(),
1558 };
1559 assert!(revision.validate().is_ok());
1560 revision.capabilities.push(CapabilityPin {
1561 id: "action.example".into(),
1562 contract_version: "2".into(),
1563 content_digest: "capability-digest-v2".into(),
1564 });
1565 assert!(revision.validate().is_err());
1566 revision.capabilities.pop();
1567 revision.kernel_abi_version.clear();
1568 assert!(revision.validate().is_err());
1569
1570 let external = CapabilityManifest::action(
1571 "action.notify",
1572 "1",
1573 "digest",
1574 Effect::ExternalWrite,
1575 IdempotencyMode::NeverAutomaticRetry,
1576 false,
1577 );
1578 assert_eq!(
1579 external.required_guards,
1580 BTreeSet::from([GuardKind::Authorization])
1581 );
1582 assert!(external.validate().is_ok());
1583 assert_eq!(external.config_schema, serde_json::json!({"type":"object"}));
1584 let mut invalid_schema = external.clone();
1585 invalid_schema.config_schema = serde_json::json!({"type":7});
1586 assert!(invalid_schema.validate().is_err());
1587 let mut invalid = external;
1588 invalid.retry.max_attempts = 0;
1589 assert!(invalid.validate().is_err());
1590
1591 let mut wrong_kind = funds_manifest();
1592 wrong_kind.kind = CapabilityKind::Expression;
1593 assert!(wrong_kind.validate().is_err());
1594 wrong_kind.kind = CapabilityKind::Action;
1595 wrong_kind.idempotency_mode = IdempotencyMode::Native;
1596 wrong_kind.supports_reconciliation = false;
1597 assert!(wrong_kind.validate().is_ok());
1598 wrong_kind.lifecycle = CapabilityLifecycle::Disabled;
1599 assert!(!wrong_kind.can_start_new_work());
1600 }
1601
1602 #[test]
1603 fn trigger_schedule_lifecycle_and_control_validate_boundaries() {
1604 let now = Utc::now();
1605 let mut trigger = TriggerEnvelope {
1606 event_id: "event".into(),
1607 event_type: "example".into(),
1608 source: "source".into(),
1609 schema_version: "1".into(),
1610 tenant_id: "tenant".parse().unwrap(),
1611 subject_id: "subject".parse().unwrap(),
1612 aggregate_id: "aggregate".into(),
1613 source_sequence: Some(1),
1614 observed_version: None,
1615 occurred_at: now,
1616 received_at: now,
1617 watermark: None,
1618 correlation_key: "key".into(),
1619 dedup_key: "dedup".into(),
1620 cursor: None,
1621 payload: Value::Null,
1622 trace_context: Value::Null,
1623 };
1624 assert!(trigger.validate().is_ok());
1625 trigger.occurred_at = now + chrono::Duration::minutes(6);
1626 assert!(trigger.validate().is_err());
1627
1628 let valid_schedule = SchedulePolicy {
1629 cadence: ScheduleCadence::Cron {
1630 expression: "0 0 * * * *".into(),
1631 },
1632 timezone: "Asia/Shanghai".into(),
1633 catch_up: CatchUpPolicy::CatchUpOnce,
1634 };
1635 assert!(valid_schedule.validate().is_ok());
1636 for invalid in [
1637 SchedulePolicy {
1638 timezone: "Nowhere/Invalid".into(),
1639 ..valid_schedule.clone()
1640 },
1641 SchedulePolicy {
1642 cadence: ScheduleCadence::FixedRate { milliseconds: 0 },
1643 ..valid_schedule.clone()
1644 },
1645 SchedulePolicy {
1646 catch_up: CatchUpPolicy::CatchUpAll { limit: 0 },
1647 ..valid_schedule
1648 },
1649 ] {
1650 assert!(invalid.validate().is_err());
1651 }
1652
1653 let mut lifecycle = LifecyclePolicy::run_once();
1654 assert!(lifecycle.validate().is_ok());
1655 lifecycle.starts_at = Some(now);
1656 lifecycle.expires_at = Some(now);
1657 assert!(lifecycle.validate().is_err());
1658 lifecycle.starts_at = None;
1659 lifecycle.expires_at = None;
1660 lifecycle.on_expiry = ExpiryPolicy::Cancel;
1661 lifecycle.drain_deadline = Some(now);
1662 assert!(lifecycle.validate().is_err());
1663
1664 let mut command = ControlCommand {
1665 scope: ControlScope::Instance,
1666 scope_id: "instance".into(),
1667 mode: ControlMode::Paused,
1668 operator_subject_id: "operator".parse().unwrap(),
1669 reason: "maintenance".into(),
1670 override_expires_at: None,
1671 };
1672 assert!(command.validate().is_ok());
1673 command.reason.clear();
1674 assert!(command.validate().is_err());
1675 }
1676
1677 #[test]
1678 fn funds_intent_requires_reservation_scope_and_retry_class() {
1679 let now = Utc::now();
1680 let mut intent = ActionIntent {
1681 id: "intent".into(),
1682 tenant_id: "tenant".parse().unwrap(),
1683 instance_id: "instance".parse().unwrap(),
1684 run_id: "run".parse().unwrap(),
1685 capability: CapabilityPin {
1686 id: "action.example".into(),
1687 contract_version: "1".into(),
1688 content_digest: "digest".into(),
1689 },
1690 idempotency_key: "idempotency".into(),
1691 state: ActionState::Prepared,
1692 input: Value::Null,
1693 effect: Effect::Funds,
1694 retry_class: IdempotencyMode::ReconcileBeforeRetry,
1695 control_epochs: ControlEpochs::default(),
1696 resource_scope_id: "resource".into(),
1697 lease_epoch: 1,
1698 action_epoch: 1,
1699 deadline: None,
1700 reservation: Some(ResourceReservationRef {
1701 reservation_id: "reservation".into(),
1702 fencing_token: 1,
1703 }),
1704 created_at: now,
1705 };
1706 assert!(intent.validate().is_ok());
1707 assert!(intent.validate_prepared().is_ok());
1708 intent.reservation = None;
1709 assert!(intent.validate().is_err());
1710 intent.reservation = Some(ResourceReservationRef {
1711 reservation_id: "reservation".into(),
1712 fencing_token: 1,
1713 });
1714 intent.resource_scope_id.clear();
1715 assert!(intent.validate().is_err());
1716 intent.resource_scope_id = "resource".into();
1717 intent.retry_class = IdempotencyMode::None;
1718 assert!(intent.validate().is_err());
1719 intent.retry_class = IdempotencyMode::ReconcileBeforeRetry;
1720 intent.state = ActionState::Authorized;
1721 assert!(intent.validate_prepared().is_err());
1722 assert!(!ActionState::Prepared.dispatch_committed());
1723 assert!(ActionState::Unknown.dispatch_committed());
1724 }
1725
1726 fn trigger_binding() -> TriggerBinding {
1727 TriggerBinding {
1728 id: "binding".into(),
1729 revision: 1,
1730 source: "source".into(),
1731 event_type: "event".into(),
1732 instance_id: "instance".parse().unwrap(),
1733 branch_id: None,
1734 predicate: serde_json::json!({}),
1735 ordering: OrderingPolicy::Commutative,
1736 starts_at: None,
1737 expires_at: None,
1738 gap_wait_ms: default_gap_wait_ms(),
1739 gap_limit: default_gap_limit(),
1740 }
1741 }
1742
1743 #[test]
1744 fn trigger_binding_rejects_blank_ids_bad_gaps_and_inverted_windows() {
1745 assert!(trigger_binding().validate().is_ok());
1746 let blank = TriggerBinding {
1747 source: " ".into(),
1748 ..trigger_binding()
1749 };
1750 assert!(blank.validate().is_err());
1751 for gap_wait_ms in [0, 3_600_001] {
1752 let binding = TriggerBinding {
1753 gap_wait_ms,
1754 ..trigger_binding()
1755 };
1756 assert!(binding.validate().is_err(), "gap_wait_ms {gap_wait_ms}");
1757 }
1758 for gap_limit in [0, 10_001] {
1759 let binding = TriggerBinding {
1760 gap_limit,
1761 ..trigger_binding()
1762 };
1763 assert!(binding.validate().is_err(), "gap_limit {gap_limit}");
1764 }
1765 let array_predicate = TriggerBinding {
1766 predicate: serde_json::json!([1]),
1767 ..trigger_binding()
1768 };
1769 assert!(array_predicate.validate().is_err());
1770 let now = Utc::now();
1771 let inverted = TriggerBinding {
1772 starts_at: Some(now),
1773 expires_at: Some(now),
1774 ..trigger_binding()
1775 };
1776 assert!(inverted.validate().is_err());
1777 let ordered = TriggerBinding {
1778 starts_at: Some(now),
1779 expires_at: Some(now + chrono::Duration::seconds(1)),
1780 ..trigger_binding()
1781 };
1782 assert!(ordered.validate().is_ok());
1783 let defaults: TriggerBinding = serde_json::from_value(serde_json::json!({
1784 "id": "binding", "revision": 1, "source": "s", "event_type": "e",
1785 "instance_id": "i", "predicate": {}, "ordering": "commutative",
1786 "starts_at": null, "expires_at": null
1787 }))
1788 .unwrap();
1789 assert_eq!(defaults.gap_wait_ms, 30_000);
1790 assert_eq!(defaults.gap_limit, 100);
1791 }
1792
1793 #[test]
1794 fn execution_profile_revision_requires_every_provider_name() {
1795 let profile = ExecutionProfileRevision {
1796 id: "profile".into(),
1797 revision: 1,
1798 content_digest: "sha256:profile".into(),
1799 mode: ExecutionMode::Paper,
1800 durability_grade: DurabilityGrade::Standard,
1801 trigger_provider: "triggers".into(),
1802 data_provider: "data".into(),
1803 clock_model: "database".into(),
1804 action_provider: "actions".into(),
1805 models: BTreeMap::new(),
1806 environment: Value::Null,
1807 policy_bundle: Value::Null,
1808 connection_bindings: BTreeMap::new(),
1809 };
1810 assert!(profile.validate().is_ok());
1811 let blank_provider = ExecutionProfileRevision {
1812 action_provider: String::new(),
1813 ..profile.clone()
1814 };
1815 assert!(blank_provider.validate().is_err());
1816 let blank_digest = ExecutionProfileRevision {
1817 content_digest: " ".into(),
1818 ..profile
1819 };
1820 assert!(blank_digest.validate().is_err());
1821 }
1822
1823 #[test]
1824 fn retry_backoff_grows_exponentially_with_bounded_jitter_and_cap() {
1825 let policy = RetryPolicy {
1826 max_attempts: 5,
1827 timeout_ms: 1_000,
1828 initial_backoff_ms: 100,
1829 max_backoff_ms: 1_000,
1830 };
1831 assert_eq!(policy.backoff_ms(1, 0), 100);
1832 assert_eq!(policy.backoff_ms(2, 0), 200);
1833 assert_eq!(policy.backoff_ms(3, 0), 400);
1834 assert_eq!(policy.backoff_ms(1, 24), 124);
1836 assert_eq!(policy.backoff_ms(1, 25), 100);
1837 assert_eq!(policy.backoff_ms(4, 249), 849);
1839 assert_eq!(policy.backoff_ms(5, 0), 1_000);
1841 assert_eq!(policy.backoff_ms(5, 249), 1_000);
1842 assert_eq!(policy.backoff_ms(40, u64::MAX), 1_000);
1843 assert_eq!(policy.backoff_ms(0, 0), 100);
1845 }
1846
1847 #[test]
1848 fn lifecycle_policy_rejects_inconsistent_windows_and_zero_counts() {
1849 let now = Utc::now();
1850 let later = now + chrono::Duration::hours(1);
1851 assert!(LifecyclePolicy::run_once().validate().is_ok());
1852 let inverted = LifecyclePolicy {
1853 starts_at: Some(later),
1854 expires_at: Some(now),
1855 ..LifecyclePolicy::run_once()
1856 };
1857 assert!(inverted.validate().is_err());
1858 let drain_without_policy = LifecyclePolicy {
1859 on_expiry: ExpiryPolicy::Cancel,
1860 drain_deadline: Some(later),
1861 ..LifecyclePolicy::run_once()
1862 };
1863 assert!(drain_without_policy.validate().is_err());
1864 let drain_before_expiry = LifecyclePolicy {
1865 expires_at: Some(later),
1866 drain_deadline: Some(now),
1867 ..LifecyclePolicy::run_once()
1868 };
1869 assert!(drain_before_expiry.validate().is_err());
1870 let zero_timeout = LifecyclePolicy {
1871 event_idle_timeout_ms: Some(0),
1872 ..LifecyclePolicy::run_once()
1873 };
1874 assert!(zero_timeout.validate().is_err());
1875 for completion in [
1876 CompletionPolicy::AfterMatchedEvaluations(0),
1877 CompletionPolicy::AfterSuccessfulRuns(0),
1878 ] {
1879 let zero_count = LifecyclePolicy {
1880 completion,
1881 ..LifecyclePolicy::run_once()
1882 };
1883 assert!(zero_count.validate().is_err());
1884 }
1885 let bounded = LifecyclePolicy {
1886 starts_at: Some(now),
1887 expires_at: Some(later),
1888 completion: CompletionPolicy::AfterSuccessfulRuns(2),
1889 event_idle_timeout_ms: Some(1_000),
1890 progress_timeout_ms: Some(1_000),
1891 on_expiry: ExpiryPolicy::Drain,
1892 drain_deadline: Some(later + chrono::Duration::minutes(1)),
1893 };
1894 assert!(bounded.validate().is_ok());
1895 }
1896}