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    /// Target branch; omitted legacy requests resolve only for a single-branch spec.
66    #[serde(default)]
67    pub branch_id: Option<af_context::BranchId>,
68    /// JSON object the event payload must contain (`@>`) to create a delivery.
69    pub predicate: Value,
70    /// How out-of-order source sequences are handled.
71    pub ordering: OrderingPolicy,
72    /// Earliest time the binding or instance is active.
73    pub starts_at: Option<DateTime<Utc>>,
74    /// When the record stops being valid.
75    pub expires_at: Option<DateTime<Utc>>,
76    /// How long a missing source sequence may block before it is reported as a gap.
77    #[serde(default = "default_gap_wait_ms")]
78    pub gap_wait_ms: u64,
79    /// Maximum buffered out-of-order deliveries before the source is blocked.
80    #[serde(default = "default_gap_limit")]
81    pub gap_limit: u32,
82}
83
84impl TriggerBinding {
85    /// Reject blank identifiers, out-of-range gap settings and inverted windows.
86    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/// Authoritative, append-only fact recorded by a committed transition.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct WorkflowEvent {
130    /// Workflow instance this record refers to.
131    pub instance_id: InstanceId,
132    /// Position in the instance's event log.
133    pub sequence: i64,
134    /// Stable machine-readable event type.
135    pub event_type: String,
136    /// Structured payload.
137    pub payload: Value,
138    /// Content hash that makes the referenced artifact immutable.
139    pub content_digest: String,
140    /// When the event happened.
141    pub occurred_at: DateTime<Utc>,
142}
143
144/// Where an execution profile is allowed to act.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ExecutionMode {
148    /// In-memory dry run; no external effects.
149    Simulation,
150    /// Historical replay.
151    Backtest,
152    /// Live data, simulated effects.
153    Paper,
154    /// Real external effects.
155    Live,
156}
157
158/// Durability the deployment must provide for an execution profile.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum DurabilityGrade {
162    /// Up to five minutes RPO for ordinary work.
163    Standard,
164    /// Committed intents synchronously preserved before dispatch; required for funds actions.
165    FundsGrade,
166}
167
168/// Immutable execution profile: mode, durability grade and provider bindings an instance pins.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct ExecutionProfileRevision {
171    /// Stable identifier of this record.
172    pub id: String,
173    /// Monotonic revision number.
174    pub revision: u64,
175    /// Content hash that makes the referenced artifact immutable.
176    pub content_digest: String,
177    /// Execution mode this record was produced under.
178    pub mode: ExecutionMode,
179    /// Durability the deployment guarantees for this profile.
180    pub durability_grade: DurabilityGrade,
181    /// Provider that feeds triggers.
182    pub trigger_provider: String,
183    /// Provider that feeds market or reference data.
184    pub data_provider: String,
185    /// Clock source; `database` is the only kernel-supported value.
186    pub clock_model: String,
187    /// Provider that dispatches actions.
188    pub action_provider: String,
189    /// Model bindings by role.
190    #[serde(default)]
191    pub models: BTreeMap<String, Value>,
192    /// Environment settings the product interprets.
193    #[serde(default)]
194    pub environment: Value,
195    /// Product policy bundle applied to permissions and guards.
196    #[serde(default)]
197    pub policy_bundle: Value,
198    /// Named external connections by role.
199    #[serde(default)]
200    pub connection_bindings: BTreeMap<String, String>,
201}
202
203impl ExecutionProfileRevision {
204    /// Reject blank identifiers and provider names.
205    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/// Exact capability identity: id, contract version and content digest.
224#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
225pub struct CapabilityPin {
226    /// Stable identifier of this record.
227    pub id: String,
228    /// Contract version of the capability.
229    pub contract_version: String,
230    /// Content hash that makes the referenced artifact immutable.
231    pub content_digest: String,
232}
233
234/// Immutable, digest-locked revision of a workflow spec with its pinned capabilities.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct WorkflowRevision {
237    /// Workflow definition this record belongs to.
238    pub definition_id: String,
239    /// Monotonic revision number.
240    pub revision: u64,
241    /// Content hash that makes the referenced artifact immutable.
242    pub content_digest: String,
243    /// Kernel ABI the revision was validated against.
244    pub kernel_abi_version: String,
245    /// Digest over the pinned capabilities.
246    pub dependency_set_digest: String,
247    /// Versions of generic expressions used by the spec.
248    #[serde(default)]
249    pub expression_versions: BTreeMap<String, String>,
250    /// Capabilities the spec may dispatch.
251    #[serde(default)]
252    pub capabilities: Vec<CapabilityPin>,
253    /// Where the spec came from (template, draft command, author).
254    #[serde(default)]
255    pub template_provenance: Value,
256    /// The validated spec.
257    pub spec: crate::Spec,
258}
259
260impl WorkflowRevision {
261    /// Check this contract's invariants; returns the first violation.
262    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/// Role a capability plays in a graph.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum CapabilityKind {
293    /// Produces events.
294    Trigger,
295    /// Pure or read-only transform.
296    Expression,
297    /// Authorization, freshness, reservation or policy check dominating an action.
298    Guard,
299    /// External effect dispatched through an intent.
300    Action,
301    /// Terminal consumer.
302    Sink,
303}
304
305/// Side-effect class of a capability.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum Effect {
309    /// No I/O.
310    Pure,
311    /// Reads external state.
312    Read,
313    /// Writes kernel-owned state only.
314    InternalWrite,
315    /// Writes external state; requires authorization dominance.
316    ExternalWrite,
317    /// Moves value; requires authorization, freshness, reservation and funds-grade durability.
318    Funds,
319}
320
321/// Whether repeating a dispatch is safe.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum IdempotencyMode {
325    /// Not idempotent; never retried automatically.
326    None,
327    /// The provider deduplicates by idempotency key; safe to retry.
328    Native,
329    /// Must reconcile the previous attempt before dispatching again.
330    ReconcileBeforeRetry,
331    /// Retry only through an explicit operator action.
332    NeverAutomaticRetry,
333}
334
335/// Deployment state of a capability provider.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum CapabilityLifecycle {
339    /// Registered but not yet serving.
340    Installed,
341    /// Serving new and existing work.
342    Active,
343    /// Serving pinned work only; new pins rejected.
344    Deprecated,
345    /// Not serving; pinned work waits.
346    Disabled,
347    /// Temporarily unreachable.
348    Unavailable,
349    /// Blocks every action immediately.
350    EmergencyRevoked,
351}
352
353/// Automatic retry policy of a capability.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct RetryPolicy {
356    /// Total dispatch attempts allowed.
357    pub max_attempts: u32,
358    /// Per-attempt deadline.
359    pub timeout_ms: u64,
360    /// Backoff after the first failure.
361    pub initial_backoff_ms: u64,
362    /// Cap on exponential backoff.
363    pub max_backoff_ms: u64,
364}
365
366impl RetryPolicy {
367    /// Exponential backoff for the 1-based `attempt`, capped at `max_backoff_ms`
368    /// with up to 25% deterministic jitter derived from `jitter_seed`.
369    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/// Immutable contract of a trigger, expression, guard, action or sink.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct CapabilityManifest {
386    /// Stable identifier of this record.
387    pub id: String,
388    /// Contract version of the capability.
389    pub contract_version: String,
390    /// Content hash that makes the referenced artifact immutable.
391    pub content_digest: String,
392    /// Discriminator naming the variant of this record.
393    pub kind: CapabilityKind,
394    /// JSON Schema for node configuration exposed to authoring clients.
395    #[serde(default = "object_schema")]
396    pub config_schema: Value,
397    /// JSON Schema for provider input.
398    pub input_schema: Value,
399    /// JSON Schema for provider output.
400    pub output_schema: Value,
401    /// Side-effect class of the action.
402    pub effect: Effect,
403    /// Whether equal inputs always produce equal outputs.
404    pub deterministic: bool,
405    /// Retry safety class.
406    pub idempotency_mode: IdempotencyMode,
407    /// Retry policy.
408    pub retry: RetryPolicy,
409    /// Permissions granted or required.
410    #[serde(default)]
411    pub permissions: BTreeSet<String>,
412    /// Guards that must dominate the action.
413    #[serde(default)]
414    pub required_guards: BTreeSet<GuardKind>,
415    /// Which inputs may be LLM-tainted.
416    #[serde(default)]
417    pub taint_rules: Value,
418    /// Declared cost for budgeting.
419    #[serde(default)]
420    pub resource_cost: Value,
421    /// Usable in simulation.
422    pub supports_simulation: bool,
423    /// Usable in backtest replay.
424    pub supports_replay: bool,
425    /// Usable in paper mode.
426    pub supports_paper: bool,
427    /// Usable live.
428    pub supports_live: bool,
429    /// Clock guarantees the provider needs.
430    #[serde(default)]
431    pub clock_requirements: Value,
432    /// Data freshness the provider needs.
433    #[serde(default)]
434    pub data_requirements: Value,
435    /// Whether the provider can observe a dispatched action's outcome.
436    pub supports_reconciliation: bool,
437    /// Deployment state.
438    pub lifecycle: CapabilityLifecycle,
439}
440
441fn object_schema() -> Value {
442    serde_json::json!({"type":"object"})
443}
444
445impl CapabilityManifest {
446    /// Manifest for an action capability with conservative defaults (one attempt, 30 s timeout).
447    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    /// Check this contract's invariants; returns the first violation.
497    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    /// Whether new intents may pin this provider (`installed` or `active`).
544    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/// Role a guard plays on an action's dominating path.
555#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
556#[serde(rename_all = "snake_case")]
557pub enum GuardKind {
558    /// Caller may perform the effect.
559    Authorization,
560    /// Inputs are recent enough.
561    Freshness,
562    /// Resources are reserved and fenced.
563    Reservation,
564    /// Product policy allows it.
565    Policy,
566}
567
568/// How a binding treats source sequence order.
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[serde(rename_all = "snake_case")]
571pub enum OrderingPolicy {
572    /// Reject a gap; the next accepted sequence must be previous + 1.
573    StrictSequence,
574    /// Order does not matter.
575    Commutative,
576    /// Only the latest event matters.
577    LatestStateReconcile,
578    /// Reject anything older than the last accepted sequence.
579    RejectUnordered,
580}
581
582/// Immutable source event as delivered by a trigger adapter.
583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
584pub struct TriggerEnvelope {
585    /// Source-unique event id.
586    pub event_id: String,
587    /// Stable machine-readable event type.
588    pub event_type: String,
589    /// Trigger adapter that delivered the event.
590    pub source: String,
591    /// Projection schema version.
592    pub schema_version: String,
593    /// Tenant that owns this record.
594    pub tenant_id: TenantId,
595    /// Subject (user or service principal) acting on or owning this record.
596    pub subject_id: SubjectId,
597    /// Aggregate the event belongs to (for ordering).
598    pub aggregate_id: String,
599    /// Per-aggregate sequence from the source.
600    pub source_sequence: Option<i64>,
601    /// Version of the observed object, if any.
602    pub observed_version: Option<String>,
603    /// When the event happened.
604    pub occurred_at: DateTime<Utc>,
605    /// When the receipt arrived.
606    pub received_at: DateTime<Utc>,
607    /// Source watermark up to which events are complete.
608    pub watermark: Option<DateTime<Utc>>,
609    /// Key that correlates related events.
610    pub correlation_key: String,
611    /// Key that deduplicates redeliveries.
612    pub dedup_key: String,
613    /// Source cursor after this event.
614    pub cursor: Option<String>,
615    /// Structured payload.
616    pub payload: Value,
617    /// Distributed tracing context.
618    #[serde(default)]
619    pub trace_context: Value,
620}
621
622impl TriggerEnvelope {
623    /// Check this contract's invariants; returns the first violation.
624    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/// Cursor to resume an admitted source adapter after restart.
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct SourceAdapterState {
658    /// Exact source.
659    pub source: af_context::WorkflowSourceId,
660    /// Opaque provider cursor.
661    pub cursor: String,
662    /// Event whose transaction last advanced the cursor; absent for an initial seed.
663    pub source_event_id: Option<String>,
664}
665/// Receipt returned only after source fact, binding snapshot, deliveries and cursor commit.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667pub struct SourceIngestionReceipt {
668    /// Durable event identity safe for adapter ACK.
669    pub event_id: String,
670    /// True for an identical committed retry.
671    pub duplicate: bool,
672    /// Newly inserted deliveries; duplicate retries report zero.
673    pub deliveries: u64,
674    /// Current committed resume cursor, including one advanced after this event.
675    pub committed_cursor: Option<String>,
676}
677
678/// When an instance completes on its own.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
680#[serde(rename_all = "snake_case")]
681pub enum CompletionPolicy {
682    /// Only an operator stops it.
683    ExplicitStop,
684    /// After the first consumed trigger or timer.
685    FirstTrigger,
686    /// After the first evaluation that matched.
687    FirstMatch,
688    /// After the first action reaches a terminal state.
689    FirstActionTerminal,
690    /// After the first successful evaluation.
691    FirstSuccess,
692    /// After this many matched evaluations.
693    AfterMatchedEvaluations(u64),
694    /// After this many successful evaluations.
695    AfterSuccessfulRuns(u64),
696}
697
698/// What happens when a lifecycle deadline passes.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "snake_case")]
701pub enum ExpiryPolicy {
702    /// Stop new work; let dispatched actions finish.
703    Drain,
704    /// Stop and request cancellation.
705    Cancel,
706}
707
708/// How a schedule fires.
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(rename_all = "snake_case")]
711pub enum ScheduleCadence {
712    /// Cron expression evaluated in the schedule's timezone.
713    Cron {
714        /// Six- or seven-field cron expression.
715        expression: String,
716    },
717    /// Fixed period measured from the scheduled time.
718    FixedRate {
719        /// Period in milliseconds.
720        milliseconds: u64,
721    },
722    /// Fixed delay measured from the previous completion.
723    FixedDelay {
724        /// Delay in milliseconds.
725        milliseconds: u64,
726    },
727}
728
729/// How missed schedule windows are handled.
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum CatchUpPolicy {
733    /// Run only the most recent due occurrence.
734    Skip,
735    /// Run once, listing the missed windows it covers.
736    CatchUpOnce,
737    /// Run every missed occurrence oldest first, at most `limit` per transition.
738    CatchUpAll {
739        /// Maximum occurrences per transition.
740        limit: u32,
741    },
742}
743
744/// Cadence, timezone and catch-up behavior of a schedule.
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
746pub struct SchedulePolicy {
747    /// Cron, fixed-rate or fixed-delay.
748    pub cadence: ScheduleCadence,
749    /// IANA timezone for cron evaluation and DST.
750    pub timezone: String,
751    /// Missed-window behavior.
752    pub catch_up: CatchUpPolicy,
753}
754
755impl SchedulePolicy {
756    /// Check this contract's invariants; returns the first violation.
757    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/// Start, completion, timeout and expiry rules evaluated on database time.
787#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
788pub struct LifecyclePolicy {
789    /// Earliest time the binding or instance is active.
790    pub starts_at: Option<DateTime<Utc>>,
791    /// When the record stops being valid.
792    pub expires_at: Option<DateTime<Utc>>,
793    /// When the instance completes.
794    pub completion: CompletionPolicy,
795    /// Terminalize after this long without a consumed event.
796    pub event_idle_timeout_ms: Option<u64>,
797    /// Terminalize after this long without a committed transition.
798    pub progress_timeout_ms: Option<u64>,
799    /// Drain or cancel on expiry.
800    pub on_expiry: ExpiryPolicy,
801    /// Hard stop for draining.
802    pub drain_deadline: Option<DateTime<Utc>>,
803}
804
805impl LifecyclePolicy {
806    /// One-shot lifecycle: completes on the first success, drains on expiry.
807    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    /// Check this contract's invariants; returns the first violation.
820    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/// How a step ended.
862#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
863#[serde(rename_all = "snake_case")]
864pub enum StepOutcomeKind {
865    /// Terminal success.
866    Succeeded,
867    /// Filtered out.
868    Skipped,
869    /// Parked on a wake condition.
870    Waiting,
871    /// Errored.
872    Failed,
873    /// Cancelled.
874    Cancelled,
875}
876
877/// Recorded outcome of one step.
878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
879pub struct StepOutcome {
880    /// Discriminator naming the variant of this record.
881    pub kind: StepOutcomeKind,
882    /// Stable code for material outcomes.
883    pub reason_code: Option<String>,
884    /// What resumes a waiting step.
885    #[serde(default)]
886    pub wake_condition: Value,
887}
888
889/// Lifecycle of an action intent; `dispatch_committed` is the point of no return.
890#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(rename_all = "snake_case")]
892pub enum ActionState {
893    /// Created by a transition; not yet authorized.
894    Prepared,
895    /// Waiting for a human confirmation.
896    AwaitingConfirmation,
897    /// Guards passed; may be dispatched.
898    Authorized,
899    /// Effect committed; cancellation can no longer claim it was not performed.
900    DispatchCommitted,
901    /// Provider reports progress.
902    Executing,
903    /// Terminal success.
904    Succeeded,
905    /// Terminal rejection.
906    Rejected,
907    /// Failed; waits for its backoff, then dispatches again.
908    Retryable,
909    /// Outcome unknown; waits for reconciliation.
910    Unknown,
911    /// Outcome established by reconciliation.
912    Reconciled,
913}
914
915impl ActionState {
916    /// Whether the state machine allows `self -> next`.
917    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    /// Whether the effect may already have happened.
936    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/// Control epochs an intent was created under; any bump makes it stale.
951#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
952pub struct ControlEpochs {
953    /// Tenant control epoch.
954    pub tenant: i64,
955    /// Resource control epoch.
956    pub resource: i64,
957    /// Instance control epoch.
958    pub instance: i64,
959}
960
961/// Scope of a control command.
962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
963#[serde(rename_all = "snake_case")]
964pub enum ControlScope {
965    /// Whole tenant.
966    Tenant,
967    /// One external resource.
968    Resource,
969    /// One instance.
970    Instance,
971}
972
973/// Operating mode set by a control command.
974#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
975#[serde(rename_all = "snake_case")]
976pub enum ControlMode {
977    /// Normal operation.
978    Running,
979    /// No new evaluations or dispatches.
980    Paused,
981    /// Terminal stop.
982    Stopped,
983    /// Immediate stop that also revokes providers.
984    EmergencyStopped,
985}
986
987/// Operator command that bumps a control epoch.
988#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
989pub struct ControlCommand {
990    /// Scope kind.
991    pub scope: ControlScope,
992    /// Scope identity.
993    pub scope_id: String,
994    /// Execution mode this record was produced under.
995    pub mode: ControlMode,
996    /// Operator issuing the command.
997    pub operator_subject_id: SubjectId,
998    /// Human-readable reason.
999    pub reason: String,
1000    /// When an override lapses.
1001    pub override_expires_at: Option<DateTime<Utc>>,
1002}
1003
1004impl ControlCommand {
1005    /// Check this contract's invariants; returns the first violation.
1006    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/// Reference to a product-owned fenced reservation.
1015#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1016pub struct ResourceReservationRef {
1017    /// Reservation identity.
1018    pub reservation_id: String,
1019    /// Fence the reservation was taken under.
1020    pub fencing_token: i64,
1021}
1022
1023/// Durable intent to perform one external effect, created inside a fenced transition.
1024#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1025pub struct ActionIntent {
1026    /// Stable identifier of this record.
1027    pub id: String,
1028    /// Tenant that owns this record.
1029    pub tenant_id: TenantId,
1030    /// Workflow instance this record refers to.
1031    pub instance_id: InstanceId,
1032    /// Run this record belongs to.
1033    pub run_id: RunId,
1034    /// Pinned capability (id, contract version, content digest).
1035    pub capability: CapabilityPin,
1036    /// Caller-supplied key that makes repeated submissions return the first result.
1037    pub idempotency_key: String,
1038    /// Current lifecycle state.
1039    pub state: ActionState,
1040    /// Structured input handed to the provider.
1041    pub input: Value,
1042    /// Side-effect class of the action.
1043    pub effect: Effect,
1044    /// Idempotency class that decides whether automatic retry is safe.
1045    pub retry_class: IdempotencyMode,
1046    /// Control epochs at creation.
1047    pub control_epochs: ControlEpochs,
1048    /// Resource the effect targets; empty when unscoped.
1049    pub resource_scope_id: String,
1050    /// Instance lease version at creation.
1051    pub lease_epoch: i64,
1052    /// Bumped on every claim; fences claim owners.
1053    pub action_epoch: i64,
1054    /// Latest time by which the work must finish.
1055    pub deadline: Option<DateTime<Utc>>,
1056    /// Fenced reservation for funds actions.
1057    pub reservation: Option<ResourceReservationRef>,
1058    /// When the record was created (database time).
1059    pub created_at: DateTime<Utc>,
1060}
1061
1062impl ActionIntent {
1063    /// Check this contract's invariants; returns the first violation.
1064    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    /// Validate an intent at the only creation boundary. Later states are
1094    /// reached exclusively through the fenced store transition.
1095    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/// Provider's answer to a dispatch.
1107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1108pub struct ActionReceipt {
1109    /// Stable identifier of this record.
1110    pub id: String,
1111    /// Intent this refers to.
1112    pub action_intent_id: String,
1113    /// Provider version that produced it.
1114    pub provider_version: String,
1115    /// When the receipt arrived.
1116    pub received_at: DateTime<Utc>,
1117    /// State the provider reports.
1118    pub outcome: ActionState,
1119    /// Structured payload.
1120    pub payload: Value,
1121    /// Digest of the raw provider response; deduplicates receipts.
1122    pub raw_receipt_digest: String,
1123}
1124
1125/// Observed state of a dispatched action.
1126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1127pub struct ActionObservation {
1128    /// Stable identifier of this record.
1129    pub id: String,
1130    /// Intent this refers to.
1131    pub action_intent_id: String,
1132    /// Provider version that produced it.
1133    pub provider_version: String,
1134    /// When the state was observed.
1135    pub observed_at: DateTime<Utc>,
1136    /// Current lifecycle state.
1137    pub state: String,
1138    /// External resource created or affected.
1139    pub resource_ref: Option<Value>,
1140    /// Digest of the raw provider response; deduplicates receipts.
1141    pub raw_receipt_digest: String,
1142    /// Whether this observation ends the action.
1143    pub terminal: bool,
1144    /// A reconciliation provider sets this only after proving that repeating
1145    /// the same idempotent action is safe.
1146    #[serde(default)]
1147    pub retry_authorized: bool,
1148}
1149
1150/// A missing source-sequence range that blocks ordered delivery for an aggregate.
1151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1152pub struct SourceGap {
1153    /// Tenant that owns this record.
1154    pub tenant_id: TenantId,
1155    /// Source the gap belongs to.
1156    pub source: String,
1157    /// Aggregate whose sequence has the hole.
1158    pub aggregate_id: String,
1159    /// First missing sequence (inclusive).
1160    pub missing_from: i64,
1161    /// Last missing sequence (inclusive).
1162    pub missing_to: i64,
1163    /// When waiting for the gap expires and delivery blocks.
1164    pub deadline: DateTime<Utc>,
1165    /// Gap status (`waiting`, `blocked`, `unblocked`).
1166    pub status: String,
1167}
1168
1169/// Scope of a resource reservation.
1170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1171#[serde(rename_all = "snake_case")]
1172pub enum ReservationScope {
1173    /// Whole tenant.
1174    Tenant,
1175    /// One external resource.
1176    Resource,
1177}
1178
1179/// Lifecycle of a reservation.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(rename_all = "snake_case")]
1182pub enum ReservationState {
1183    /// Held.
1184    Reserved,
1185    /// Used by a committed dispatch.
1186    Consumed,
1187    /// Given back.
1188    Released,
1189    /// Lapsed unused.
1190    Expired,
1191}
1192
1193/// Mirror of a product-owned fenced reservation.
1194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1195pub struct ResourceReservation {
1196    /// Stable identifier of this record.
1197    pub id: String,
1198    /// Tenant that owns this record.
1199    pub tenant_id: TenantId,
1200    /// Scope kind.
1201    pub scope: ReservationScope,
1202    /// Scope identity.
1203    pub scope_id: String,
1204    /// Kind of resource reserved.
1205    pub resource_kind: String,
1206    /// Reserved amount as a decimal string.
1207    pub amount: String,
1208    /// Product policy version that granted it.
1209    pub policy_version: String,
1210    /// When the record stops being valid.
1211    pub expires_at: DateTime<Utc>,
1212    /// Fence the reservation was taken under.
1213    pub fencing_token: i64,
1214    /// Provider-side reservation id.
1215    pub provider_reservation_id: Option<String>,
1216    /// Current lifecycle state.
1217    pub state: ReservationState,
1218}
1219
1220/// A value with provenance and freshness metadata.
1221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1222pub struct ObservedValue<T> {
1223    /// The value carried by this record.
1224    pub value: T,
1225    /// Where the value was observed.
1226    pub source: String,
1227    /// When the state was observed.
1228    pub observed_at: DateTime<Utc>,
1229    /// When the receipt arrived.
1230    pub received_at: DateTime<Utc>,
1231    /// Version of the source.
1232    pub source_version: String,
1233    /// Quality label from the source.
1234    pub quality: String,
1235    /// Digest of the value.
1236    pub digest: String,
1237}
1238
1239impl<T> ObservedValue<T> {
1240    /// Whether the value is fresh enough for `max_age` at `now`.
1241    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/// Everything a decision depended on, for replay and audit.
1254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1255pub struct DecisionSnapshot {
1256    /// Digests of the inputs.
1257    pub input_digests: Vec<String>,
1258    /// Config version.
1259    pub instance_config_version: String,
1260    /// Product policy version that granted it.
1261    pub policy_version: String,
1262    /// Capabilities in force.
1263    pub capability_versions: Vec<CapabilityPin>,
1264    /// Execution profile revision in force.
1265    pub execution_profile_revision_id: String,
1266    /// Context values used.
1267    #[serde(default)]
1268    pub context_snapshot: Value,
1269    /// Policy values used.
1270    #[serde(default)]
1271    pub policy_snapshot: Value,
1272    /// Which Agent artifacts influenced the decision.
1273    #[serde(default)]
1274    pub agent_provenance: Value,
1275}
1276
1277/// Who supplied a parameter value.
1278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1279#[serde(rename_all = "snake_case")]
1280pub enum ParameterProvenance {
1281    /// Explicitly supplied by the user.
1282    UserSupplied,
1283    /// Product default.
1284    ProductDefault,
1285    /// Inferred by an Agent.
1286    AgentInferred,
1287    /// Computed from other parameters.
1288    Derived,
1289}
1290
1291/// A parameter with its provenance.
1292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1293pub struct ParameterValue {
1294    /// The value carried by this record.
1295    pub value: Value,
1296    /// Who supplied it.
1297    pub provenance: ParameterProvenance,
1298}
1299
1300/// Declared parameter and its live-mode requirements.
1301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1302pub struct ParameterSpec {
1303    /// Display name.
1304    pub name: String,
1305    /// Must be present.
1306    pub required: bool,
1307    /// Must be user-supplied in live mode.
1308    pub required_explicit_for_live: bool,
1309}
1310
1311/// Check supplied parameters against their specs for `mode`.
1312pub 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/// Parameters that block a live run.
1341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1342#[error("missing explicit workflow requirements: {parameters:?}")]
1343pub struct MissingRequirements {
1344    /// Missing or non-explicit parameter names.
1345    pub parameters: Vec<String>,
1346}
1347
1348/// Budget reserved atomically before a root operation starts children.
1349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1350pub struct RootOperationBudget {
1351    /// Root operation identity.
1352    pub root_operation_id: String,
1353    /// Maximum nesting depth.
1354    pub max_depth: u32,
1355    /// Maximum descendants.
1356    pub descendant_limit: u32,
1357    /// Maximum runs.
1358    pub run_limit: u32,
1359    /// Token budget.
1360    pub token_budget: u64,
1361    /// Cost budget in micro-units.
1362    pub cost_budget_micros: u64,
1363    /// Maximum actions.
1364    pub action_budget: u32,
1365    /// Latest time by which the work must finish.
1366    pub deadline: DateTime<Utc>,
1367    /// Permissions children may not exceed.
1368    #[serde(default)]
1369    pub permission_ceiling: BTreeSet<String>,
1370}
1371
1372/// Persisted Agent decision reused after recovery instead of re-asking the model.
1373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1374pub struct DecisionArtifact {
1375    /// Stable identifier of this record.
1376    pub id: String,
1377    /// Root operation identity.
1378    pub root_operation_id: String,
1379    /// Content hash that makes the referenced artifact immutable.
1380    pub content_digest: String,
1381    /// Model identifier as registered in the model registry.
1382    pub model: String,
1383    /// Digest of the prompt.
1384    pub prompt_digest: String,
1385    /// Model output.
1386    pub output: Value,
1387    /// When the record was created (database time).
1388    pub created_at: DateTime<Utc>,
1389}
1390
1391/// Operator-facing diagnostic attached to an instance.
1392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1393pub struct DiagnosticRecord {
1394    /// Stable diagnostic code.
1395    pub code: String,
1396    /// Human-readable message.
1397    pub message: String,
1398    /// When it was recorded.
1399    pub at: DateTime<Utc>,
1400    /// Structured detail.
1401    #[serde(default)]
1402    pub detail: Value,
1403}
1404
1405/// Rebuildable read model of an instance; never authorizes or schedules work.
1406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1407pub struct ExecutionProjection {
1408    /// Projection schema version.
1409    pub schema_version: String,
1410    /// Authoritative event sequence the projection was built from.
1411    pub source_sequence: i64,
1412    /// Steps in progress.
1413    pub current_steps: Vec<String>,
1414    /// Steps completed.
1415    pub completed_steps: Vec<String>,
1416    /// What the instance waits for.
1417    pub waiting_on: Option<Value>,
1418    /// Most recent decision.
1419    pub last_decision: Option<Value>,
1420    /// Steps that may run next.
1421    pub next_possible_steps: Vec<String>,
1422    /// Next scheduled evaluation.
1423    pub next_trigger_at: Option<DateTime<Utc>>,
1424    /// Actions prepared but not dispatched.
1425    pub planned_actions: Vec<String>,
1426    /// Most recent diagnostic.
1427    pub latest_diagnostic: Option<DiagnosticRecord>,
1428    /// Product-defined progress.
1429    pub progress: Value,
1430    /// When the record stops being valid.
1431    pub expires_at: Option<DateTime<Utc>>,
1432}
1433
1434/// A contract value violates its invariants.
1435#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1436pub enum ContractError {
1437    /// Invalid workflow contract.
1438    #[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        // Jitter is deterministic and never exceeds 25% of the base.
1835        assert_eq!(policy.backoff_ms(1, 24), 124);
1836        assert_eq!(policy.backoff_ms(1, 25), 100);
1837        // Jitter is taken modulo the ceiling (800 / 4 = 200 here).
1838        assert_eq!(policy.backoff_ms(4, 249), 849);
1839        // The cap holds for large attempts and for base + jitter.
1840        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        // Attempt 0 behaves like the first attempt instead of underflowing.
1844        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}