Skip to main content

af_workflow/
contract.rs

1//! Stable contracts for durable workflow execution.
2//!
3//! These types describe immutable definitions, accepted source facts and
4//! external effects. Products provide capabilities; the workflow kernel owns
5//! lifecycle, fencing and persistence semantics.
6
7use 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/// Named workflow whose revisions are immutable.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct WorkflowDefinition {
17    /// Stable identifier of this record.
18    pub id: String,
19    /// Display name.
20    pub name: String,
21}
22
23/// One durable instance pinned to a revision and execution profile for its lifetime.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct WorkflowInstance {
26    /// Stable identifier of this record.
27    pub id: String,
28    /// Tenant that owns this record.
29    pub tenant_id: TenantId,
30    /// Subject (user or service principal) acting on or owning this record.
31    pub subject_id: SubjectId,
32    /// Workflow definition this record belongs to.
33    pub definition_id: String,
34    /// Monotonic revision number.
35    pub revision: u64,
36    /// Execution profile the instance pins.
37    pub execution_profile_id: String,
38    /// Pinned execution profile revision.
39    pub execution_profile_revision: u64,
40    /// Lifecycle policy applied on database time.
41    pub lifecycle: LifecyclePolicy,
42    /// Current lifecycle status.
43    pub status: String,
44    /// CAS version of the authoritative state.
45    pub state_version: i64,
46    /// Sequence of the last authoritative event.
47    pub event_sequence: i64,
48    /// Instance control epoch; pause/resume bump it and stale workers fail.
49    pub control_epoch: i64,
50}
51
52/// Subscription of an instance to a source event type, with a JSON-containment predicate.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct TriggerBinding {
55    /// Stable identifier of this record.
56    pub id: String,
57    /// Monotonic revision number.
58    pub revision: u64,
59    /// Source the binding subscribes to.
60    pub source: String,
61    /// Stable machine-readable event type.
62    pub event_type: String,
63    /// Workflow instance this record refers to.
64    pub instance_id: InstanceId,
65    /// JSON object the event payload must contain (`@>`) to create a delivery.
66    pub predicate: Value,
67    /// How out-of-order source sequences are handled.
68    pub ordering: OrderingPolicy,
69    /// Earliest time the binding or instance is active.
70    pub starts_at: Option<DateTime<Utc>>,
71    /// When the record stops being valid.
72    pub expires_at: Option<DateTime<Utc>>,
73    /// How long a missing source sequence may block before it is reported as a gap.
74    #[serde(default = "default_gap_wait_ms")]
75    pub gap_wait_ms: u64,
76    /// Maximum buffered out-of-order deliveries before the source is blocked.
77    #[serde(default = "default_gap_limit")]
78    pub gap_limit: u32,
79}
80
81impl TriggerBinding {
82    /// Reject blank identifiers, out-of-range gap settings and inverted windows.
83    pub fn validate(&self) -> Result<(), ContractError> {
84        for (name, value) in [
85            ("trigger binding id", self.id.as_str()),
86            ("trigger source", self.source.as_str()),
87            ("trigger event_type", self.event_type.as_str()),
88            ("trigger instance_id", self.instance_id.as_str()),
89        ] {
90            required(name, value)?;
91        }
92        if self.gap_wait_ms == 0 || self.gap_wait_ms > 3_600_000 {
93            return Err(ContractError::Invalid(
94                "trigger gap_wait_ms must be between 1 and 3600000".into(),
95            ));
96        }
97        if self.gap_limit == 0 || self.gap_limit > 10_000 {
98            return Err(ContractError::Invalid(
99                "trigger gap_limit must be between 1 and 10000".into(),
100            ));
101        }
102        if !self.predicate.is_object() {
103            return Err(ContractError::Invalid(
104                "trigger predicate must be a JSON object".into(),
105            ));
106        }
107        if matches!((self.starts_at, self.expires_at), (Some(start), Some(end)) if end <= start) {
108            return Err(ContractError::Invalid(
109                "trigger expires_at must be after starts_at".into(),
110            ));
111        }
112        Ok(())
113    }
114}
115
116const fn default_gap_wait_ms() -> u64 {
117    30_000
118}
119
120const fn default_gap_limit() -> u32 {
121    100
122}
123
124/// Authoritative, append-only fact recorded by a committed transition.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct WorkflowEvent {
127    /// Workflow instance this record refers to.
128    pub instance_id: InstanceId,
129    /// Position in the instance's event log.
130    pub sequence: i64,
131    /// Stable machine-readable event type.
132    pub event_type: String,
133    /// Structured payload.
134    pub payload: Value,
135    /// Content hash that makes the referenced artifact immutable.
136    pub content_digest: String,
137    /// When the event happened.
138    pub occurred_at: DateTime<Utc>,
139}
140
141/// Where an execution profile is allowed to act.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum ExecutionMode {
145    /// In-memory dry run; no external effects.
146    Simulation,
147    /// Historical replay.
148    Backtest,
149    /// Live data, simulated effects.
150    Paper,
151    /// Real external effects.
152    Live,
153}
154
155/// Durability the deployment must provide for an execution profile.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum DurabilityGrade {
159    /// Up to five minutes RPO for ordinary work.
160    Standard,
161    /// Committed intents synchronously preserved before dispatch; required for funds actions.
162    FundsGrade,
163}
164
165/// Immutable execution profile: mode, durability grade and provider bindings an instance pins.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ExecutionProfileRevision {
168    /// Stable identifier of this record.
169    pub id: String,
170    /// Monotonic revision number.
171    pub revision: u64,
172    /// Content hash that makes the referenced artifact immutable.
173    pub content_digest: String,
174    /// Execution mode this record was produced under.
175    pub mode: ExecutionMode,
176    /// Durability the deployment guarantees for this profile.
177    pub durability_grade: DurabilityGrade,
178    /// Provider that feeds triggers.
179    pub trigger_provider: String,
180    /// Provider that feeds market or reference data.
181    pub data_provider: String,
182    /// Clock source; `database` is the only kernel-supported value.
183    pub clock_model: String,
184    /// Provider that dispatches actions.
185    pub action_provider: String,
186    /// Model bindings by role.
187    #[serde(default)]
188    pub models: BTreeMap<String, Value>,
189    /// Environment settings the product interprets.
190    #[serde(default)]
191    pub environment: Value,
192    /// Product policy bundle applied to permissions and guards.
193    #[serde(default)]
194    pub policy_bundle: Value,
195    /// Named external connections by role.
196    #[serde(default)]
197    pub connection_bindings: BTreeMap<String, String>,
198}
199
200impl ExecutionProfileRevision {
201    /// Reject blank identifiers and provider names.
202    pub fn validate(&self) -> Result<(), ContractError> {
203        for (name, value) in [
204            ("execution profile id", self.id.as_str()),
205            (
206                "execution profile content_digest",
207                self.content_digest.as_str(),
208            ),
209            ("trigger_provider", self.trigger_provider.as_str()),
210            ("data_provider", self.data_provider.as_str()),
211            ("clock_model", self.clock_model.as_str()),
212            ("action_provider", self.action_provider.as_str()),
213        ] {
214            required(name, value)?;
215        }
216        Ok(())
217    }
218}
219
220/// Exact capability identity: id, contract version and content digest.
221#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
222pub struct CapabilityPin {
223    /// Stable identifier of this record.
224    pub id: String,
225    /// Contract version of the capability.
226    pub contract_version: String,
227    /// Content hash that makes the referenced artifact immutable.
228    pub content_digest: String,
229}
230
231/// Immutable, digest-locked revision of a workflow spec with its pinned capabilities.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct WorkflowRevision {
234    /// Workflow definition this record belongs to.
235    pub definition_id: String,
236    /// Monotonic revision number.
237    pub revision: u64,
238    /// Content hash that makes the referenced artifact immutable.
239    pub content_digest: String,
240    /// Kernel ABI the revision was validated against.
241    pub kernel_abi_version: String,
242    /// Digest over the pinned capabilities.
243    pub dependency_set_digest: String,
244    /// Versions of generic expressions used by the spec.
245    #[serde(default)]
246    pub expression_versions: BTreeMap<String, String>,
247    /// Capabilities the spec may dispatch.
248    #[serde(default)]
249    pub capabilities: Vec<CapabilityPin>,
250    /// Where the spec came from (template, draft command, author).
251    #[serde(default)]
252    pub template_provenance: Value,
253    /// The validated spec.
254    pub spec: crate::Spec,
255}
256
257impl WorkflowRevision {
258    /// Check this contract's invariants; returns the first violation.
259    pub fn validate(&self) -> Result<(), ContractError> {
260        required("definition_id", &self.definition_id)?;
261        required("content_digest", &self.content_digest)?;
262        required("kernel_abi_version", &self.kernel_abi_version)?;
263        required("dependency_set_digest", &self.dependency_set_digest)?;
264        self.spec
265            .validate_structure()
266            .map_err(|error| ContractError::Invalid(error.to_string()))?;
267        let unique = self
268            .capabilities
269            .iter()
270            .map(|pin| (&pin.id, &pin.contract_version))
271            .collect::<BTreeSet<_>>();
272        if unique.len() != self.capabilities.len() {
273            return Err(ContractError::Invalid(
274                "capability pins must be unique by id and contract version".into(),
275            ));
276        }
277        for pin in &self.capabilities {
278            required("capability id", &pin.id)?;
279            required("capability contract_version", &pin.contract_version)?;
280            required("capability content_digest", &pin.content_digest)?;
281        }
282        Ok(())
283    }
284}
285
286/// Role a capability plays in a graph.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum CapabilityKind {
290    /// Produces events.
291    Trigger,
292    /// Pure or read-only transform.
293    Expression,
294    /// Authorization, freshness, reservation or policy check dominating an action.
295    Guard,
296    /// External effect dispatched through an intent.
297    Action,
298    /// Terminal consumer.
299    Sink,
300}
301
302/// Side-effect class of a capability.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum Effect {
306    /// No I/O.
307    Pure,
308    /// Reads external state.
309    Read,
310    /// Writes kernel-owned state only.
311    InternalWrite,
312    /// Writes external state; requires authorization dominance.
313    ExternalWrite,
314    /// Moves value; requires authorization, freshness, reservation and funds-grade durability.
315    Funds,
316}
317
318/// Whether repeating a dispatch is safe.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "snake_case")]
321pub enum IdempotencyMode {
322    /// Not idempotent; never retried automatically.
323    None,
324    /// The provider deduplicates by idempotency key; safe to retry.
325    Native,
326    /// Must reconcile the previous attempt before dispatching again.
327    ReconcileBeforeRetry,
328    /// Retry only through an explicit operator action.
329    NeverAutomaticRetry,
330}
331
332/// Deployment state of a capability provider.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum CapabilityLifecycle {
336    /// Registered but not yet serving.
337    Installed,
338    /// Serving new and existing work.
339    Active,
340    /// Serving pinned work only; new pins rejected.
341    Deprecated,
342    /// Not serving; pinned work waits.
343    Disabled,
344    /// Temporarily unreachable.
345    Unavailable,
346    /// Blocks every action immediately.
347    EmergencyRevoked,
348}
349
350/// Automatic retry policy of a capability.
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352pub struct RetryPolicy {
353    /// Total dispatch attempts allowed.
354    pub max_attempts: u32,
355    /// Per-attempt deadline.
356    pub timeout_ms: u64,
357    /// Backoff after the first failure.
358    pub initial_backoff_ms: u64,
359    /// Cap on exponential backoff.
360    pub max_backoff_ms: u64,
361}
362
363impl RetryPolicy {
364    /// Exponential backoff for the 1-based `attempt`, capped at `max_backoff_ms`
365    /// with up to 25% deterministic jitter derived from `jitter_seed`.
366    pub fn backoff_ms(&self, attempt: u32, jitter_seed: u64) -> u64 {
367        let factor = 1_u64
368            .checked_shl(attempt.saturating_sub(1).min(20))
369            .unwrap_or(u64::MAX);
370        let base = self
371            .initial_backoff_ms
372            .saturating_mul(factor)
373            .min(self.max_backoff_ms);
374        let jitter_ceiling = (base / 4).max(1);
375        base.saturating_add(jitter_seed % jitter_ceiling)
376            .min(self.max_backoff_ms)
377    }
378}
379
380/// Immutable contract of a trigger, expression, guard, action or sink.
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
382pub struct CapabilityManifest {
383    /// Stable identifier of this record.
384    pub id: String,
385    /// Contract version of the capability.
386    pub contract_version: String,
387    /// Content hash that makes the referenced artifact immutable.
388    pub content_digest: String,
389    /// Discriminator naming the variant of this record.
390    pub kind: CapabilityKind,
391    /// JSON Schema for provider input.
392    pub input_schema: Value,
393    /// JSON Schema for provider output.
394    pub output_schema: Value,
395    /// Side-effect class of the action.
396    pub effect: Effect,
397    /// Whether equal inputs always produce equal outputs.
398    pub deterministic: bool,
399    /// Retry safety class.
400    pub idempotency_mode: IdempotencyMode,
401    /// Retry policy.
402    pub retry: RetryPolicy,
403    /// Permissions granted or required.
404    #[serde(default)]
405    pub permissions: BTreeSet<String>,
406    /// Guards that must dominate the action.
407    #[serde(default)]
408    pub required_guards: BTreeSet<GuardKind>,
409    /// Which inputs may be LLM-tainted.
410    #[serde(default)]
411    pub taint_rules: Value,
412    /// Declared cost for budgeting.
413    #[serde(default)]
414    pub resource_cost: Value,
415    /// Usable in simulation.
416    pub supports_simulation: bool,
417    /// Usable in backtest replay.
418    pub supports_replay: bool,
419    /// Usable in paper mode.
420    pub supports_paper: bool,
421    /// Usable live.
422    pub supports_live: bool,
423    /// Clock guarantees the provider needs.
424    #[serde(default)]
425    pub clock_requirements: Value,
426    /// Data freshness the provider needs.
427    #[serde(default)]
428    pub data_requirements: Value,
429    /// Whether the provider can observe a dispatched action's outcome.
430    pub supports_reconciliation: bool,
431    /// Deployment state.
432    pub lifecycle: CapabilityLifecycle,
433}
434
435impl CapabilityManifest {
436    /// Manifest for an action capability with conservative defaults (one attempt, 30 s timeout).
437    pub fn action(
438        id: impl Into<String>,
439        contract_version: impl Into<String>,
440        content_digest: impl Into<String>,
441        effect: Effect,
442        idempotency_mode: IdempotencyMode,
443        supports_reconciliation: bool,
444    ) -> Self {
445        let required_guards = match effect {
446            Effect::Funds => BTreeSet::from([
447                GuardKind::Authorization,
448                GuardKind::Freshness,
449                GuardKind::Reservation,
450            ]),
451            Effect::ExternalWrite => BTreeSet::from([GuardKind::Authorization]),
452            _ => BTreeSet::new(),
453        };
454        Self {
455            id: id.into(),
456            contract_version: contract_version.into(),
457            content_digest: content_digest.into(),
458            kind: CapabilityKind::Action,
459            input_schema: serde_json::json!({"type": "object"}),
460            output_schema: serde_json::json!({"type": "object"}),
461            effect,
462            deterministic: false,
463            idempotency_mode,
464            retry: RetryPolicy {
465                max_attempts: 1,
466                timeout_ms: 30_000,
467                initial_backoff_ms: 100,
468                max_backoff_ms: 5_000,
469            },
470            permissions: BTreeSet::new(),
471            required_guards,
472            taint_rules: Value::Null,
473            resource_cost: Value::Null,
474            supports_simulation: true,
475            supports_replay: false,
476            supports_paper: true,
477            supports_live: true,
478            clock_requirements: Value::Null,
479            data_requirements: Value::Null,
480            supports_reconciliation,
481            lifecycle: CapabilityLifecycle::Active,
482        }
483    }
484
485    /// Check this contract's invariants; returns the first violation.
486    pub fn validate(&self) -> Result<(), ContractError> {
487        required("capability id", &self.id)?;
488        required("contract_version", &self.contract_version)?;
489        required("content_digest", &self.content_digest)?;
490        if self.retry.max_attempts == 0
491            || self.retry.timeout_ms == 0
492            || self.retry.initial_backoff_ms == 0
493            || self.retry.max_backoff_ms < self.retry.initial_backoff_ms
494        {
495            return Err(ContractError::Invalid(
496                "retry attempts, timeout and backoff bounds are invalid".into(),
497            ));
498        }
499        if self.effect == Effect::Funds {
500            if self.kind != CapabilityKind::Action {
501                return Err(ContractError::Invalid(
502                    "funds effect is only valid for action capabilities".into(),
503                ));
504            }
505            if !self.supports_reconciliation && self.idempotency_mode != IdempotencyMode::Native {
506                return Err(ContractError::Invalid(
507                    "funds actions require native idempotency or reconciliation".into(),
508                ));
509            }
510            for guard in [
511                GuardKind::Authorization,
512                GuardKind::Freshness,
513                GuardKind::Reservation,
514            ] {
515                if !self.required_guards.contains(&guard) {
516                    return Err(ContractError::Invalid(format!(
517                        "funds action must require {guard:?} guard"
518                    )));
519                }
520            }
521        }
522        Ok(())
523    }
524
525    /// Whether new intents may pin this provider (`installed` or `active`).
526    pub fn can_start_new_work(&self) -> bool {
527        matches!(
528            self.lifecycle,
529            CapabilityLifecycle::Installed
530                | CapabilityLifecycle::Active
531                | CapabilityLifecycle::Deprecated
532        )
533    }
534}
535
536/// Role a guard plays on an action's dominating path.
537#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum GuardKind {
540    /// Caller may perform the effect.
541    Authorization,
542    /// Inputs are recent enough.
543    Freshness,
544    /// Resources are reserved and fenced.
545    Reservation,
546    /// Product policy allows it.
547    Policy,
548}
549
550/// How a binding treats source sequence order.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum OrderingPolicy {
554    /// Reject a gap; the next accepted sequence must be previous + 1.
555    StrictSequence,
556    /// Order does not matter.
557    Commutative,
558    /// Only the latest event matters.
559    LatestStateReconcile,
560    /// Reject anything older than the last accepted sequence.
561    RejectUnordered,
562}
563
564/// Immutable source event as delivered by a trigger adapter.
565#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
566pub struct TriggerEnvelope {
567    /// Source-unique event id.
568    pub event_id: String,
569    /// Stable machine-readable event type.
570    pub event_type: String,
571    /// Trigger adapter that delivered the event.
572    pub source: String,
573    /// Projection schema version.
574    pub schema_version: String,
575    /// Tenant that owns this record.
576    pub tenant_id: TenantId,
577    /// Subject (user or service principal) acting on or owning this record.
578    pub subject_id: SubjectId,
579    /// Aggregate the event belongs to (for ordering).
580    pub aggregate_id: String,
581    /// Per-aggregate sequence from the source.
582    pub source_sequence: Option<i64>,
583    /// Version of the observed object, if any.
584    pub observed_version: Option<String>,
585    /// When the event happened.
586    pub occurred_at: DateTime<Utc>,
587    /// When the receipt arrived.
588    pub received_at: DateTime<Utc>,
589    /// Source watermark up to which events are complete.
590    pub watermark: Option<DateTime<Utc>>,
591    /// Key that correlates related events.
592    pub correlation_key: String,
593    /// Key that deduplicates redeliveries.
594    pub dedup_key: String,
595    /// Source cursor after this event.
596    pub cursor: Option<String>,
597    /// Structured payload.
598    pub payload: Value,
599    /// Distributed tracing context.
600    #[serde(default)]
601    pub trace_context: Value,
602}
603
604impl TriggerEnvelope {
605    /// Check this contract's invariants; returns the first violation.
606    pub fn validate(&self) -> Result<(), ContractError> {
607        for (name, value) in [
608            ("event_id", self.event_id.as_str()),
609            ("event_type", self.event_type.as_str()),
610            ("source", self.source.as_str()),
611            ("schema_version", self.schema_version.as_str()),
612            ("tenant_id", self.tenant_id.as_str()),
613            ("aggregate_id", self.aggregate_id.as_str()),
614            ("correlation_key", self.correlation_key.as_str()),
615            ("dedup_key", self.dedup_key.as_str()),
616        ] {
617            required(name, value)?;
618        }
619        if self.occurred_at > self.received_at + chrono::Duration::minutes(5) {
620            return Err(ContractError::Invalid(
621                "occurred_at is implausibly ahead of received_at".into(),
622            ));
623        }
624        Ok(())
625    }
626}
627
628/// When an instance completes on its own.
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "snake_case")]
631pub enum CompletionPolicy {
632    /// Only an operator stops it.
633    ExplicitStop,
634    /// After the first consumed trigger or timer.
635    FirstTrigger,
636    /// After the first evaluation that matched.
637    FirstMatch,
638    /// After the first action reaches a terminal state.
639    FirstActionTerminal,
640    /// After the first successful evaluation.
641    FirstSuccess,
642    /// After this many matched evaluations.
643    AfterMatchedEvaluations(u64),
644    /// After this many successful evaluations.
645    AfterSuccessfulRuns(u64),
646}
647
648/// What happens when a lifecycle deadline passes.
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(rename_all = "snake_case")]
651pub enum ExpiryPolicy {
652    /// Stop new work; let dispatched actions finish.
653    Drain,
654    /// Stop and request cancellation.
655    Cancel,
656}
657
658/// How a schedule fires.
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660#[serde(rename_all = "snake_case")]
661pub enum ScheduleCadence {
662    /// Cron expression evaluated in the schedule's timezone.
663    Cron {
664        /// Six- or seven-field cron expression.
665        expression: String,
666    },
667    /// Fixed period measured from the scheduled time.
668    FixedRate {
669        /// Period in milliseconds.
670        milliseconds: u64,
671    },
672    /// Fixed delay measured from the previous completion.
673    FixedDelay {
674        /// Delay in milliseconds.
675        milliseconds: u64,
676    },
677}
678
679/// How missed schedule windows are handled.
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "snake_case")]
682pub enum CatchUpPolicy {
683    /// Run only the most recent due occurrence.
684    Skip,
685    /// Run once, listing the missed windows it covers.
686    CatchUpOnce,
687    /// Run every missed occurrence oldest first, at most `limit` per transition.
688    CatchUpAll {
689        /// Maximum occurrences per transition.
690        limit: u32,
691    },
692}
693
694/// Cadence, timezone and catch-up behavior of a schedule.
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696pub struct SchedulePolicy {
697    /// Cron, fixed-rate or fixed-delay.
698    pub cadence: ScheduleCadence,
699    /// IANA timezone for cron evaluation and DST.
700    pub timezone: String,
701    /// Missed-window behavior.
702    pub catch_up: CatchUpPolicy,
703}
704
705impl SchedulePolicy {
706    /// Check this contract's invariants; returns the first violation.
707    pub fn validate(&self) -> Result<(), ContractError> {
708        self.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
709            ContractError::Invalid(format!("unknown IANA timezone '{}'", self.timezone))
710        })?;
711        match &self.cadence {
712            ScheduleCadence::Cron { expression } => {
713                expression.parse::<cron::Schedule>().map_err(|error| {
714                    ContractError::Invalid(format!("invalid cron '{expression}': {error}"))
715                })?;
716            }
717            ScheduleCadence::FixedRate { milliseconds }
718            | ScheduleCadence::FixedDelay { milliseconds }
719                if *milliseconds == 0 =>
720            {
721                return Err(ContractError::Invalid(
722                    "schedule interval must be positive".into(),
723                ));
724            }
725            _ => {}
726        }
727        if matches!(self.catch_up, CatchUpPolicy::CatchUpAll { limit: 0 }) {
728            return Err(ContractError::Invalid(
729                "catch_up_all limit must be positive".into(),
730            ));
731        }
732        Ok(())
733    }
734}
735
736/// Start, completion, timeout and expiry rules evaluated on database time.
737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
738pub struct LifecyclePolicy {
739    /// Earliest time the binding or instance is active.
740    pub starts_at: Option<DateTime<Utc>>,
741    /// When the record stops being valid.
742    pub expires_at: Option<DateTime<Utc>>,
743    /// When the instance completes.
744    pub completion: CompletionPolicy,
745    /// Terminalize after this long without a consumed event.
746    pub event_idle_timeout_ms: Option<u64>,
747    /// Terminalize after this long without a committed transition.
748    pub progress_timeout_ms: Option<u64>,
749    /// Drain or cancel on expiry.
750    pub on_expiry: ExpiryPolicy,
751    /// Hard stop for draining.
752    pub drain_deadline: Option<DateTime<Utc>>,
753}
754
755impl LifecyclePolicy {
756    /// One-shot lifecycle: completes on the first success, drains on expiry.
757    pub fn run_once() -> Self {
758        Self {
759            starts_at: None,
760            expires_at: None,
761            completion: CompletionPolicy::FirstSuccess,
762            event_idle_timeout_ms: None,
763            progress_timeout_ms: None,
764            on_expiry: ExpiryPolicy::Drain,
765            drain_deadline: None,
766        }
767    }
768
769    /// Check this contract's invariants; returns the first violation.
770    pub fn validate(&self) -> Result<(), ContractError> {
771        if self
772            .starts_at
773            .zip(self.expires_at)
774            .is_some_and(|(a, b)| a >= b)
775        {
776            return Err(ContractError::Invalid(
777                "lifecycle starts_at must be before expires_at".into(),
778            ));
779        }
780        if self.drain_deadline.is_some() && self.on_expiry != ExpiryPolicy::Drain {
781            return Err(ContractError::Invalid(
782                "drain_deadline requires on_expiry=drain".into(),
783            ));
784        }
785        if self
786            .expires_at
787            .zip(self.drain_deadline)
788            .is_some_and(|(expires, drain)| drain <= expires)
789        {
790            return Err(ContractError::Invalid(
791                "lifecycle drain_deadline must be after expires_at".into(),
792            ));
793        }
794        if self.event_idle_timeout_ms == Some(0) || self.progress_timeout_ms == Some(0) {
795            return Err(ContractError::Invalid(
796                "lifecycle timeouts must be positive".into(),
797            ));
798        }
799        if matches!(
800            self.completion,
801            CompletionPolicy::AfterMatchedEvaluations(0) | CompletionPolicy::AfterSuccessfulRuns(0)
802        ) {
803            return Err(ContractError::Invalid(
804                "lifecycle completion count must be positive".into(),
805            ));
806        }
807        Ok(())
808    }
809}
810
811/// How a step ended.
812#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
813#[serde(rename_all = "snake_case")]
814pub enum StepOutcomeKind {
815    /// Terminal success.
816    Succeeded,
817    /// Filtered out.
818    Skipped,
819    /// Parked on a wake condition.
820    Waiting,
821    /// Errored.
822    Failed,
823    /// Cancelled.
824    Cancelled,
825}
826
827/// Recorded outcome of one step.
828#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
829pub struct StepOutcome {
830    /// Discriminator naming the variant of this record.
831    pub kind: StepOutcomeKind,
832    /// Stable code for material outcomes.
833    pub reason_code: Option<String>,
834    /// What resumes a waiting step.
835    #[serde(default)]
836    pub wake_condition: Value,
837}
838
839/// Lifecycle of an action intent; `dispatch_committed` is the point of no return.
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(rename_all = "snake_case")]
842pub enum ActionState {
843    /// Created by a transition; not yet authorized.
844    Prepared,
845    /// Waiting for a human confirmation.
846    AwaitingConfirmation,
847    /// Guards passed; may be dispatched.
848    Authorized,
849    /// Effect committed; cancellation can no longer claim it was not performed.
850    DispatchCommitted,
851    /// Provider reports progress.
852    Executing,
853    /// Terminal success.
854    Succeeded,
855    /// Terminal rejection.
856    Rejected,
857    /// Failed; waits for its backoff, then dispatches again.
858    Retryable,
859    /// Outcome unknown; waits for reconciliation.
860    Unknown,
861    /// Outcome established by reconciliation.
862    Reconciled,
863}
864
865impl ActionState {
866    /// Whether the state machine allows `self -> next`.
867    pub fn can_transition_to(self, next: Self) -> bool {
868        use ActionState::*;
869        matches!(
870            (self, next),
871            (Prepared, AwaitingConfirmation | Authorized | Rejected)
872                | (AwaitingConfirmation, Authorized | Rejected)
873                | (Authorized, DispatchCommitted | Rejected)
874                | (
875                    DispatchCommitted,
876                    Executing | Succeeded | Rejected | Retryable | Unknown
877                )
878                | (Executing, Succeeded | Rejected | Retryable | Unknown)
879                | (Retryable, DispatchCommitted | Reconciled)
880                | (Unknown, Reconciled)
881                | (Succeeded | Rejected, Reconciled)
882        )
883    }
884
885    /// Whether the effect may already have happened.
886    pub fn dispatch_committed(self) -> bool {
887        matches!(
888            self,
889            Self::DispatchCommitted
890                | Self::Executing
891                | Self::Succeeded
892                | Self::Rejected
893                | Self::Retryable
894                | Self::Unknown
895                | Self::Reconciled
896        )
897    }
898}
899
900/// Control epochs an intent was created under; any bump makes it stale.
901#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
902pub struct ControlEpochs {
903    /// Tenant control epoch.
904    pub tenant: i64,
905    /// Resource control epoch.
906    pub resource: i64,
907    /// Instance control epoch.
908    pub instance: i64,
909}
910
911/// Scope of a control command.
912#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
913#[serde(rename_all = "snake_case")]
914pub enum ControlScope {
915    /// Whole tenant.
916    Tenant,
917    /// One external resource.
918    Resource,
919    /// One instance.
920    Instance,
921}
922
923/// Operating mode set by a control command.
924#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
925#[serde(rename_all = "snake_case")]
926pub enum ControlMode {
927    /// Normal operation.
928    Running,
929    /// No new evaluations or dispatches.
930    Paused,
931    /// Terminal stop.
932    Stopped,
933    /// Immediate stop that also revokes providers.
934    EmergencyStopped,
935}
936
937/// Operator command that bumps a control epoch.
938#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
939pub struct ControlCommand {
940    /// Scope kind.
941    pub scope: ControlScope,
942    /// Scope identity.
943    pub scope_id: String,
944    /// Execution mode this record was produced under.
945    pub mode: ControlMode,
946    /// Operator issuing the command.
947    pub operator_subject_id: SubjectId,
948    /// Human-readable reason.
949    pub reason: String,
950    /// When an override lapses.
951    pub override_expires_at: Option<DateTime<Utc>>,
952}
953
954impl ControlCommand {
955    /// Check this contract's invariants; returns the first violation.
956    pub fn validate(&self) -> Result<(), ContractError> {
957        required("control scope_id", &self.scope_id)?;
958        required("control operator_subject_id", &self.operator_subject_id)?;
959        required("control reason", &self.reason)?;
960        Ok(())
961    }
962}
963
964/// Reference to a product-owned fenced reservation.
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966pub struct ResourceReservationRef {
967    /// Reservation identity.
968    pub reservation_id: String,
969    /// Fence the reservation was taken under.
970    pub fencing_token: i64,
971}
972
973/// Durable intent to perform one external effect, created inside a fenced transition.
974#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
975pub struct ActionIntent {
976    /// Stable identifier of this record.
977    pub id: String,
978    /// Tenant that owns this record.
979    pub tenant_id: TenantId,
980    /// Workflow instance this record refers to.
981    pub instance_id: InstanceId,
982    /// Run this record belongs to.
983    pub run_id: RunId,
984    /// Pinned capability (id, contract version, content digest).
985    pub capability: CapabilityPin,
986    /// Caller-supplied key that makes repeated submissions return the first result.
987    pub idempotency_key: String,
988    /// Current lifecycle state.
989    pub state: ActionState,
990    /// Structured input handed to the provider.
991    pub input: Value,
992    /// Side-effect class of the action.
993    pub effect: Effect,
994    /// Idempotency class that decides whether automatic retry is safe.
995    pub retry_class: IdempotencyMode,
996    /// Control epochs at creation.
997    pub control_epochs: ControlEpochs,
998    /// Resource the effect targets; empty when unscoped.
999    pub resource_scope_id: String,
1000    /// Instance lease version at creation.
1001    pub lease_epoch: i64,
1002    /// Bumped on every claim; fences claim owners.
1003    pub action_epoch: i64,
1004    /// Latest time by which the work must finish.
1005    pub deadline: Option<DateTime<Utc>>,
1006    /// Fenced reservation for funds actions.
1007    pub reservation: Option<ResourceReservationRef>,
1008    /// When the record was created (database time).
1009    pub created_at: DateTime<Utc>,
1010}
1011
1012impl ActionIntent {
1013    /// Check this contract's invariants; returns the first violation.
1014    pub fn validate(&self) -> Result<(), ContractError> {
1015        required("action id", &self.id)?;
1016        required("action idempotency_key", &self.idempotency_key)?;
1017        if self.effect == Effect::Funds && self.reservation.is_none() {
1018            return Err(ContractError::Invalid(
1019                "funds action requires a resource reservation".into(),
1020            ));
1021        }
1022        if self.effect == Effect::Funds && self.resource_scope_id.trim().is_empty() {
1023            return Err(ContractError::Invalid(
1024                "funds action requires a resource scope".into(),
1025            ));
1026        }
1027        if self.effect == Effect::Funds && self.retry_class == IdempotencyMode::None {
1028            return Err(ContractError::Invalid(
1029                "funds action requires an explicit retry class".into(),
1030            ));
1031        }
1032        if self
1033            .deadline
1034            .is_some_and(|deadline| deadline <= self.created_at)
1035        {
1036            return Err(ContractError::Invalid(
1037                "action deadline must be after creation".into(),
1038            ));
1039        }
1040        Ok(())
1041    }
1042
1043    /// Validate an intent at the only creation boundary. Later states are
1044    /// reached exclusively through the fenced store transition.
1045    pub fn validate_prepared(&self) -> Result<(), ContractError> {
1046        self.validate()?;
1047        if self.state != ActionState::Prepared {
1048            return Err(ContractError::Invalid(
1049                "new action intent must start in prepared state".into(),
1050            ));
1051        }
1052        Ok(())
1053    }
1054}
1055
1056/// Provider's answer to a dispatch.
1057#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1058pub struct ActionReceipt {
1059    /// Stable identifier of this record.
1060    pub id: String,
1061    /// Intent this refers to.
1062    pub action_intent_id: String,
1063    /// Provider version that produced it.
1064    pub provider_version: String,
1065    /// When the receipt arrived.
1066    pub received_at: DateTime<Utc>,
1067    /// State the provider reports.
1068    pub outcome: ActionState,
1069    /// Structured payload.
1070    pub payload: Value,
1071    /// Digest of the raw provider response; deduplicates receipts.
1072    pub raw_receipt_digest: String,
1073}
1074
1075/// Observed state of a dispatched action.
1076#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1077pub struct ActionObservation {
1078    /// Stable identifier of this record.
1079    pub id: String,
1080    /// Intent this refers to.
1081    pub action_intent_id: String,
1082    /// Provider version that produced it.
1083    pub provider_version: String,
1084    /// When the state was observed.
1085    pub observed_at: DateTime<Utc>,
1086    /// Current lifecycle state.
1087    pub state: String,
1088    /// External resource created or affected.
1089    pub resource_ref: Option<Value>,
1090    /// Digest of the raw provider response; deduplicates receipts.
1091    pub raw_receipt_digest: String,
1092    /// Whether this observation ends the action.
1093    pub terminal: bool,
1094    /// A reconciliation provider sets this only after proving that repeating
1095    /// the same idempotent action is safe.
1096    #[serde(default)]
1097    pub retry_authorized: bool,
1098}
1099
1100/// A missing source-sequence range that blocks ordered delivery for an aggregate.
1101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1102pub struct SourceGap {
1103    /// Tenant that owns this record.
1104    pub tenant_id: TenantId,
1105    /// Source the gap belongs to.
1106    pub source: String,
1107    /// Aggregate whose sequence has the hole.
1108    pub aggregate_id: String,
1109    /// First missing sequence (inclusive).
1110    pub missing_from: i64,
1111    /// Last missing sequence (inclusive).
1112    pub missing_to: i64,
1113    /// When waiting for the gap expires and delivery blocks.
1114    pub deadline: DateTime<Utc>,
1115    /// Gap status (`waiting`, `blocked`, `unblocked`).
1116    pub status: String,
1117}
1118
1119/// Scope of a resource reservation.
1120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1121#[serde(rename_all = "snake_case")]
1122pub enum ReservationScope {
1123    /// Whole tenant.
1124    Tenant,
1125    /// One external resource.
1126    Resource,
1127}
1128
1129/// Lifecycle of a reservation.
1130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1131#[serde(rename_all = "snake_case")]
1132pub enum ReservationState {
1133    /// Held.
1134    Reserved,
1135    /// Used by a committed dispatch.
1136    Consumed,
1137    /// Given back.
1138    Released,
1139    /// Lapsed unused.
1140    Expired,
1141}
1142
1143/// Mirror of a product-owned fenced reservation.
1144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1145pub struct ResourceReservation {
1146    /// Stable identifier of this record.
1147    pub id: String,
1148    /// Tenant that owns this record.
1149    pub tenant_id: TenantId,
1150    /// Scope kind.
1151    pub scope: ReservationScope,
1152    /// Scope identity.
1153    pub scope_id: String,
1154    /// Kind of resource reserved.
1155    pub resource_kind: String,
1156    /// Reserved amount as a decimal string.
1157    pub amount: String,
1158    /// Product policy version that granted it.
1159    pub policy_version: String,
1160    /// When the record stops being valid.
1161    pub expires_at: DateTime<Utc>,
1162    /// Fence the reservation was taken under.
1163    pub fencing_token: i64,
1164    /// Provider-side reservation id.
1165    pub provider_reservation_id: Option<String>,
1166    /// Current lifecycle state.
1167    pub state: ReservationState,
1168}
1169
1170/// A value with provenance and freshness metadata.
1171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1172pub struct ObservedValue<T> {
1173    /// The value carried by this record.
1174    pub value: T,
1175    /// Where the value was observed.
1176    pub source: String,
1177    /// When the state was observed.
1178    pub observed_at: DateTime<Utc>,
1179    /// When the receipt arrived.
1180    pub received_at: DateTime<Utc>,
1181    /// Version of the source.
1182    pub source_version: String,
1183    /// Quality label from the source.
1184    pub quality: String,
1185    /// Digest of the value.
1186    pub digest: String,
1187}
1188
1189impl<T> ObservedValue<T> {
1190    /// Whether the value is fresh enough for `max_age` at `now`.
1191    pub fn is_accepted(
1192        &self,
1193        now: DateTime<Utc>,
1194        max_age: chrono::Duration,
1195        accepted_quality: &BTreeSet<String>,
1196    ) -> bool {
1197        self.observed_at <= now
1198            && now - self.observed_at <= max_age
1199            && accepted_quality.contains(&self.quality)
1200    }
1201}
1202
1203/// Everything a decision depended on, for replay and audit.
1204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1205pub struct DecisionSnapshot {
1206    /// Digests of the inputs.
1207    pub input_digests: Vec<String>,
1208    /// Config version.
1209    pub instance_config_version: String,
1210    /// Product policy version that granted it.
1211    pub policy_version: String,
1212    /// Capabilities in force.
1213    pub capability_versions: Vec<CapabilityPin>,
1214    /// Execution profile revision in force.
1215    pub execution_profile_revision_id: String,
1216    /// Context values used.
1217    #[serde(default)]
1218    pub context_snapshot: Value,
1219    /// Policy values used.
1220    #[serde(default)]
1221    pub policy_snapshot: Value,
1222    /// Which Agent artifacts influenced the decision.
1223    #[serde(default)]
1224    pub agent_provenance: Value,
1225}
1226
1227/// Who supplied a parameter value.
1228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1229#[serde(rename_all = "snake_case")]
1230pub enum ParameterProvenance {
1231    /// Explicitly supplied by the user.
1232    UserSupplied,
1233    /// Product default.
1234    ProductDefault,
1235    /// Inferred by an Agent.
1236    AgentInferred,
1237    /// Computed from other parameters.
1238    Derived,
1239}
1240
1241/// A parameter with its provenance.
1242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1243pub struct ParameterValue {
1244    /// The value carried by this record.
1245    pub value: Value,
1246    /// Who supplied it.
1247    pub provenance: ParameterProvenance,
1248}
1249
1250/// Declared parameter and its live-mode requirements.
1251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1252pub struct ParameterSpec {
1253    /// Display name.
1254    pub name: String,
1255    /// Must be present.
1256    pub required: bool,
1257    /// Must be user-supplied in live mode.
1258    pub required_explicit_for_live: bool,
1259}
1260
1261/// Check supplied parameters against their specs for `mode`.
1262pub fn validate_parameters(
1263    mode: ExecutionMode,
1264    specs: &[ParameterSpec],
1265    values: &BTreeMap<String, ParameterValue>,
1266) -> Result<(), MissingRequirements> {
1267    let missing = specs
1268        .iter()
1269        .filter(|spec| match values.get(&spec.name) {
1270            None => {
1271                spec.required || (mode == ExecutionMode::Live && spec.required_explicit_for_live)
1272            }
1273            Some(value) => {
1274                mode == ExecutionMode::Live
1275                    && spec.required_explicit_for_live
1276                    && value.provenance != ParameterProvenance::UserSupplied
1277            }
1278        })
1279        .map(|spec| spec.name.clone())
1280        .collect::<Vec<_>>();
1281    if missing.is_empty() {
1282        Ok(())
1283    } else {
1284        Err(MissingRequirements {
1285            parameters: missing,
1286        })
1287    }
1288}
1289
1290/// Parameters that block a live run.
1291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1292#[error("missing explicit workflow requirements: {parameters:?}")]
1293pub struct MissingRequirements {
1294    /// Missing or non-explicit parameter names.
1295    pub parameters: Vec<String>,
1296}
1297
1298/// Budget reserved atomically before a root operation starts children.
1299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1300pub struct RootOperationBudget {
1301    /// Root operation identity.
1302    pub root_operation_id: String,
1303    /// Maximum nesting depth.
1304    pub max_depth: u32,
1305    /// Maximum descendants.
1306    pub descendant_limit: u32,
1307    /// Maximum runs.
1308    pub run_limit: u32,
1309    /// Token budget.
1310    pub token_budget: u64,
1311    /// Cost budget in micro-units.
1312    pub cost_budget_micros: u64,
1313    /// Maximum actions.
1314    pub action_budget: u32,
1315    /// Latest time by which the work must finish.
1316    pub deadline: DateTime<Utc>,
1317    /// Permissions children may not exceed.
1318    #[serde(default)]
1319    pub permission_ceiling: BTreeSet<String>,
1320}
1321
1322/// Persisted Agent decision reused after recovery instead of re-asking the model.
1323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1324pub struct DecisionArtifact {
1325    /// Stable identifier of this record.
1326    pub id: String,
1327    /// Root operation identity.
1328    pub root_operation_id: String,
1329    /// Content hash that makes the referenced artifact immutable.
1330    pub content_digest: String,
1331    /// Model identifier as registered in the model registry.
1332    pub model: String,
1333    /// Digest of the prompt.
1334    pub prompt_digest: String,
1335    /// Model output.
1336    pub output: Value,
1337    /// When the record was created (database time).
1338    pub created_at: DateTime<Utc>,
1339}
1340
1341/// Operator-facing diagnostic attached to an instance.
1342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1343pub struct DiagnosticRecord {
1344    /// Stable diagnostic code.
1345    pub code: String,
1346    /// Human-readable message.
1347    pub message: String,
1348    /// When it was recorded.
1349    pub at: DateTime<Utc>,
1350    /// Structured detail.
1351    #[serde(default)]
1352    pub detail: Value,
1353}
1354
1355/// Rebuildable read model of an instance; never authorizes or schedules work.
1356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357pub struct ExecutionProjection {
1358    /// Projection schema version.
1359    pub schema_version: String,
1360    /// Authoritative event sequence the projection was built from.
1361    pub source_sequence: i64,
1362    /// Steps in progress.
1363    pub current_steps: Vec<String>,
1364    /// Steps completed.
1365    pub completed_steps: Vec<String>,
1366    /// What the instance waits for.
1367    pub waiting_on: Option<Value>,
1368    /// Most recent decision.
1369    pub last_decision: Option<Value>,
1370    /// Steps that may run next.
1371    pub next_possible_steps: Vec<String>,
1372    /// Next scheduled evaluation.
1373    pub next_trigger_at: Option<DateTime<Utc>>,
1374    /// Actions prepared but not dispatched.
1375    pub planned_actions: Vec<String>,
1376    /// Most recent diagnostic.
1377    pub latest_diagnostic: Option<DiagnosticRecord>,
1378    /// Product-defined progress.
1379    pub progress: Value,
1380    /// When the record stops being valid.
1381    pub expires_at: Option<DateTime<Utc>>,
1382}
1383
1384/// A contract value violates its invariants.
1385#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1386pub enum ContractError {
1387    /// Invalid workflow contract.
1388    #[error("invalid workflow contract: {0}")]
1389    Invalid(String),
1390}
1391
1392fn required(name: &str, value: &str) -> Result<(), ContractError> {
1393    if value.trim().is_empty() {
1394        Err(ContractError::Invalid(format!("{name} is required")))
1395    } else {
1396        Ok(())
1397    }
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403
1404    fn empty_spec() -> crate::Spec {
1405        crate::Spec {
1406            spec_id: "test".into(),
1407            version: "1".into(),
1408            description: String::new(),
1409            aliases: vec![],
1410            instance_config_schema: None,
1411            display: BTreeMap::new(),
1412            branches: vec![],
1413        }
1414    }
1415
1416    fn funds_manifest() -> CapabilityManifest {
1417        CapabilityManifest::action(
1418            "action.example",
1419            "1",
1420            "sha256:x",
1421            Effect::Funds,
1422            IdempotencyMode::ReconcileBeforeRetry,
1423            true,
1424        )
1425    }
1426
1427    #[test]
1428    fn funds_capability_requires_reconciliation_and_all_guards() {
1429        assert!(funds_manifest().validate().is_ok());
1430        let mut invalid = funds_manifest();
1431        invalid.required_guards.remove(&GuardKind::Freshness);
1432        assert!(invalid.validate().is_err());
1433        invalid.required_guards.insert(GuardKind::Freshness);
1434        invalid.supports_reconciliation = false;
1435        assert!(invalid.validate().is_err());
1436    }
1437
1438    #[test]
1439    fn action_state_never_skips_dispatch_commit() {
1440        assert!(ActionState::Authorized.can_transition_to(ActionState::DispatchCommitted));
1441        assert!(!ActionState::Authorized.can_transition_to(ActionState::Succeeded));
1442        assert!(ActionState::Unknown.can_transition_to(ActionState::Reconciled));
1443    }
1444
1445    #[test]
1446    fn live_parameters_cannot_be_silently_inferred() {
1447        let specs = [ParameterSpec {
1448            name: "account".into(),
1449            required: true,
1450            required_explicit_for_live: true,
1451        }];
1452        let inferred = BTreeMap::from([(
1453            "account".into(),
1454            ParameterValue {
1455                value: Value::String("a".into()),
1456                provenance: ParameterProvenance::AgentInferred,
1457            },
1458        )]);
1459        assert_eq!(
1460            validate_parameters(ExecutionMode::Live, &specs, &inferred)
1461                .unwrap_err()
1462                .parameters,
1463            ["account"]
1464        );
1465        assert!(validate_parameters(ExecutionMode::Paper, &specs, &inferred).is_ok());
1466    }
1467
1468    #[test]
1469    fn freshness_is_fail_closed() {
1470        let now = Utc::now();
1471        let observed = ObservedValue {
1472            value: 1,
1473            source: "source".into(),
1474            observed_at: now - chrono::Duration::seconds(2),
1475            received_at: now,
1476            source_version: "1".into(),
1477            quality: "good".into(),
1478            digest: "d".into(),
1479        };
1480        assert!(observed.is_accepted(
1481            now,
1482            chrono::Duration::seconds(3),
1483            &BTreeSet::from(["good".into()])
1484        ));
1485        assert!(!observed.is_accepted(
1486            now,
1487            chrono::Duration::seconds(1),
1488            &BTreeSet::from(["good".into()])
1489        ));
1490    }
1491
1492    #[test]
1493    fn revision_and_manifest_contracts_fail_closed() {
1494        let mut revision = WorkflowRevision {
1495            definition_id: "definition".into(),
1496            revision: 1,
1497            content_digest: "digest".into(),
1498            kernel_abi_version: "1".into(),
1499            dependency_set_digest: "dependencies".into(),
1500            expression_versions: BTreeMap::new(),
1501            capabilities: vec![CapabilityPin {
1502                id: "action.example".into(),
1503                contract_version: "1".into(),
1504                content_digest: "capability-digest".into(),
1505            }],
1506            template_provenance: Value::Null,
1507            spec: empty_spec(),
1508        };
1509        assert!(revision.validate().is_ok());
1510        revision.capabilities.push(revision.capabilities[0].clone());
1511        assert!(revision.validate().is_err());
1512        revision.capabilities.pop();
1513        revision.kernel_abi_version.clear();
1514        assert!(revision.validate().is_err());
1515
1516        let external = CapabilityManifest::action(
1517            "action.notify",
1518            "1",
1519            "digest",
1520            Effect::ExternalWrite,
1521            IdempotencyMode::NeverAutomaticRetry,
1522            false,
1523        );
1524        assert_eq!(
1525            external.required_guards,
1526            BTreeSet::from([GuardKind::Authorization])
1527        );
1528        assert!(external.validate().is_ok());
1529        let mut invalid = external;
1530        invalid.retry.max_attempts = 0;
1531        assert!(invalid.validate().is_err());
1532
1533        let mut wrong_kind = funds_manifest();
1534        wrong_kind.kind = CapabilityKind::Expression;
1535        assert!(wrong_kind.validate().is_err());
1536        wrong_kind.kind = CapabilityKind::Action;
1537        wrong_kind.idempotency_mode = IdempotencyMode::Native;
1538        wrong_kind.supports_reconciliation = false;
1539        assert!(wrong_kind.validate().is_ok());
1540        wrong_kind.lifecycle = CapabilityLifecycle::Disabled;
1541        assert!(!wrong_kind.can_start_new_work());
1542    }
1543
1544    #[test]
1545    fn trigger_schedule_lifecycle_and_control_validate_boundaries() {
1546        let now = Utc::now();
1547        let mut trigger = TriggerEnvelope {
1548            event_id: "event".into(),
1549            event_type: "example".into(),
1550            source: "source".into(),
1551            schema_version: "1".into(),
1552            tenant_id: "tenant".parse().unwrap(),
1553            subject_id: "subject".parse().unwrap(),
1554            aggregate_id: "aggregate".into(),
1555            source_sequence: Some(1),
1556            observed_version: None,
1557            occurred_at: now,
1558            received_at: now,
1559            watermark: None,
1560            correlation_key: "key".into(),
1561            dedup_key: "dedup".into(),
1562            cursor: None,
1563            payload: Value::Null,
1564            trace_context: Value::Null,
1565        };
1566        assert!(trigger.validate().is_ok());
1567        trigger.occurred_at = now + chrono::Duration::minutes(6);
1568        assert!(trigger.validate().is_err());
1569
1570        let valid_schedule = SchedulePolicy {
1571            cadence: ScheduleCadence::Cron {
1572                expression: "0 0 * * * *".into(),
1573            },
1574            timezone: "Asia/Shanghai".into(),
1575            catch_up: CatchUpPolicy::CatchUpOnce,
1576        };
1577        assert!(valid_schedule.validate().is_ok());
1578        for invalid in [
1579            SchedulePolicy {
1580                timezone: "Nowhere/Invalid".into(),
1581                ..valid_schedule.clone()
1582            },
1583            SchedulePolicy {
1584                cadence: ScheduleCadence::FixedRate { milliseconds: 0 },
1585                ..valid_schedule.clone()
1586            },
1587            SchedulePolicy {
1588                catch_up: CatchUpPolicy::CatchUpAll { limit: 0 },
1589                ..valid_schedule
1590            },
1591        ] {
1592            assert!(invalid.validate().is_err());
1593        }
1594
1595        let mut lifecycle = LifecyclePolicy::run_once();
1596        assert!(lifecycle.validate().is_ok());
1597        lifecycle.starts_at = Some(now);
1598        lifecycle.expires_at = Some(now);
1599        assert!(lifecycle.validate().is_err());
1600        lifecycle.starts_at = None;
1601        lifecycle.expires_at = None;
1602        lifecycle.on_expiry = ExpiryPolicy::Cancel;
1603        lifecycle.drain_deadline = Some(now);
1604        assert!(lifecycle.validate().is_err());
1605
1606        let mut command = ControlCommand {
1607            scope: ControlScope::Instance,
1608            scope_id: "instance".into(),
1609            mode: ControlMode::Paused,
1610            operator_subject_id: "operator".parse().unwrap(),
1611            reason: "maintenance".into(),
1612            override_expires_at: None,
1613        };
1614        assert!(command.validate().is_ok());
1615        command.reason.clear();
1616        assert!(command.validate().is_err());
1617    }
1618
1619    #[test]
1620    fn funds_intent_requires_reservation_scope_and_retry_class() {
1621        let now = Utc::now();
1622        let mut intent = ActionIntent {
1623            id: "intent".into(),
1624            tenant_id: "tenant".parse().unwrap(),
1625            instance_id: "instance".parse().unwrap(),
1626            run_id: "run".parse().unwrap(),
1627            capability: CapabilityPin {
1628                id: "action.example".into(),
1629                contract_version: "1".into(),
1630                content_digest: "digest".into(),
1631            },
1632            idempotency_key: "idempotency".into(),
1633            state: ActionState::Prepared,
1634            input: Value::Null,
1635            effect: Effect::Funds,
1636            retry_class: IdempotencyMode::ReconcileBeforeRetry,
1637            control_epochs: ControlEpochs::default(),
1638            resource_scope_id: "resource".into(),
1639            lease_epoch: 1,
1640            action_epoch: 1,
1641            deadline: None,
1642            reservation: Some(ResourceReservationRef {
1643                reservation_id: "reservation".into(),
1644                fencing_token: 1,
1645            }),
1646            created_at: now,
1647        };
1648        assert!(intent.validate().is_ok());
1649        assert!(intent.validate_prepared().is_ok());
1650        intent.reservation = None;
1651        assert!(intent.validate().is_err());
1652        intent.reservation = Some(ResourceReservationRef {
1653            reservation_id: "reservation".into(),
1654            fencing_token: 1,
1655        });
1656        intent.resource_scope_id.clear();
1657        assert!(intent.validate().is_err());
1658        intent.resource_scope_id = "resource".into();
1659        intent.retry_class = IdempotencyMode::None;
1660        assert!(intent.validate().is_err());
1661        intent.retry_class = IdempotencyMode::ReconcileBeforeRetry;
1662        intent.state = ActionState::Authorized;
1663        assert!(intent.validate_prepared().is_err());
1664        assert!(!ActionState::Prepared.dispatch_committed());
1665        assert!(ActionState::Unknown.dispatch_committed());
1666    }
1667
1668    fn trigger_binding() -> TriggerBinding {
1669        TriggerBinding {
1670            id: "binding".into(),
1671            revision: 1,
1672            source: "source".into(),
1673            event_type: "event".into(),
1674            instance_id: "instance".parse().unwrap(),
1675            predicate: serde_json::json!({}),
1676            ordering: OrderingPolicy::Commutative,
1677            starts_at: None,
1678            expires_at: None,
1679            gap_wait_ms: default_gap_wait_ms(),
1680            gap_limit: default_gap_limit(),
1681        }
1682    }
1683
1684    #[test]
1685    fn trigger_binding_rejects_blank_ids_bad_gaps_and_inverted_windows() {
1686        assert!(trigger_binding().validate().is_ok());
1687        let blank = TriggerBinding {
1688            source: "  ".into(),
1689            ..trigger_binding()
1690        };
1691        assert!(blank.validate().is_err());
1692        for gap_wait_ms in [0, 3_600_001] {
1693            let binding = TriggerBinding {
1694                gap_wait_ms,
1695                ..trigger_binding()
1696            };
1697            assert!(binding.validate().is_err(), "gap_wait_ms {gap_wait_ms}");
1698        }
1699        for gap_limit in [0, 10_001] {
1700            let binding = TriggerBinding {
1701                gap_limit,
1702                ..trigger_binding()
1703            };
1704            assert!(binding.validate().is_err(), "gap_limit {gap_limit}");
1705        }
1706        let array_predicate = TriggerBinding {
1707            predicate: serde_json::json!([1]),
1708            ..trigger_binding()
1709        };
1710        assert!(array_predicate.validate().is_err());
1711        let now = Utc::now();
1712        let inverted = TriggerBinding {
1713            starts_at: Some(now),
1714            expires_at: Some(now),
1715            ..trigger_binding()
1716        };
1717        assert!(inverted.validate().is_err());
1718        let ordered = TriggerBinding {
1719            starts_at: Some(now),
1720            expires_at: Some(now + chrono::Duration::seconds(1)),
1721            ..trigger_binding()
1722        };
1723        assert!(ordered.validate().is_ok());
1724        let defaults: TriggerBinding = serde_json::from_value(serde_json::json!({
1725            "id": "binding", "revision": 1, "source": "s", "event_type": "e",
1726            "instance_id": "i", "predicate": {}, "ordering": "commutative",
1727            "starts_at": null, "expires_at": null
1728        }))
1729        .unwrap();
1730        assert_eq!(defaults.gap_wait_ms, 30_000);
1731        assert_eq!(defaults.gap_limit, 100);
1732    }
1733
1734    #[test]
1735    fn execution_profile_revision_requires_every_provider_name() {
1736        let profile = ExecutionProfileRevision {
1737            id: "profile".into(),
1738            revision: 1,
1739            content_digest: "sha256:profile".into(),
1740            mode: ExecutionMode::Paper,
1741            durability_grade: DurabilityGrade::Standard,
1742            trigger_provider: "triggers".into(),
1743            data_provider: "data".into(),
1744            clock_model: "database".into(),
1745            action_provider: "actions".into(),
1746            models: BTreeMap::new(),
1747            environment: Value::Null,
1748            policy_bundle: Value::Null,
1749            connection_bindings: BTreeMap::new(),
1750        };
1751        assert!(profile.validate().is_ok());
1752        let blank_provider = ExecutionProfileRevision {
1753            action_provider: String::new(),
1754            ..profile.clone()
1755        };
1756        assert!(blank_provider.validate().is_err());
1757        let blank_digest = ExecutionProfileRevision {
1758            content_digest: " ".into(),
1759            ..profile
1760        };
1761        assert!(blank_digest.validate().is_err());
1762    }
1763
1764    #[test]
1765    fn retry_backoff_grows_exponentially_with_bounded_jitter_and_cap() {
1766        let policy = RetryPolicy {
1767            max_attempts: 5,
1768            timeout_ms: 1_000,
1769            initial_backoff_ms: 100,
1770            max_backoff_ms: 1_000,
1771        };
1772        assert_eq!(policy.backoff_ms(1, 0), 100);
1773        assert_eq!(policy.backoff_ms(2, 0), 200);
1774        assert_eq!(policy.backoff_ms(3, 0), 400);
1775        // Jitter is deterministic and never exceeds 25% of the base.
1776        assert_eq!(policy.backoff_ms(1, 24), 124);
1777        assert_eq!(policy.backoff_ms(1, 25), 100);
1778        // Jitter is taken modulo the ceiling (800 / 4 = 200 here).
1779        assert_eq!(policy.backoff_ms(4, 249), 849);
1780        // The cap holds for large attempts and for base + jitter.
1781        assert_eq!(policy.backoff_ms(5, 0), 1_000);
1782        assert_eq!(policy.backoff_ms(5, 249), 1_000);
1783        assert_eq!(policy.backoff_ms(40, u64::MAX), 1_000);
1784        // Attempt 0 behaves like the first attempt instead of underflowing.
1785        assert_eq!(policy.backoff_ms(0, 0), 100);
1786    }
1787
1788    #[test]
1789    fn lifecycle_policy_rejects_inconsistent_windows_and_zero_counts() {
1790        let now = Utc::now();
1791        let later = now + chrono::Duration::hours(1);
1792        assert!(LifecyclePolicy::run_once().validate().is_ok());
1793        let inverted = LifecyclePolicy {
1794            starts_at: Some(later),
1795            expires_at: Some(now),
1796            ..LifecyclePolicy::run_once()
1797        };
1798        assert!(inverted.validate().is_err());
1799        let drain_without_policy = LifecyclePolicy {
1800            on_expiry: ExpiryPolicy::Cancel,
1801            drain_deadline: Some(later),
1802            ..LifecyclePolicy::run_once()
1803        };
1804        assert!(drain_without_policy.validate().is_err());
1805        let drain_before_expiry = LifecyclePolicy {
1806            expires_at: Some(later),
1807            drain_deadline: Some(now),
1808            ..LifecyclePolicy::run_once()
1809        };
1810        assert!(drain_before_expiry.validate().is_err());
1811        let zero_timeout = LifecyclePolicy {
1812            event_idle_timeout_ms: Some(0),
1813            ..LifecyclePolicy::run_once()
1814        };
1815        assert!(zero_timeout.validate().is_err());
1816        for completion in [
1817            CompletionPolicy::AfterMatchedEvaluations(0),
1818            CompletionPolicy::AfterSuccessfulRuns(0),
1819        ] {
1820            let zero_count = LifecyclePolicy {
1821                completion,
1822                ..LifecyclePolicy::run_once()
1823            };
1824            assert!(zero_count.validate().is_err());
1825        }
1826        let bounded = LifecyclePolicy {
1827            starts_at: Some(now),
1828            expires_at: Some(later),
1829            completion: CompletionPolicy::AfterSuccessfulRuns(2),
1830            event_idle_timeout_ms: Some(1_000),
1831            progress_timeout_ms: Some(1_000),
1832            on_expiry: ExpiryPolicy::Drain,
1833            drain_deadline: Some(later + chrono::Duration::minutes(1)),
1834        };
1835        assert!(bounded.validate().is_ok());
1836    }
1837}